Compare commits

...

94 Commits

Author SHA1 Message Date
LuanRT
2b5027eb06 fix: getLyrics() only working when signed-in 2022-02-06 15:26:16 -03:00
LuanRT
0c9f7135bf docs: oops 2022-02-05 19:25:23 -03:00
LuanRT
ce8a109398 docs: update table of contents 2022-02-05 19:23:07 -03:00
LuanRT
6aaa3360e8 docs: update YouTube Music examples 2022-02-05 19:17:12 -03:00
LuanRT
89c018c431 refactor: move getLyrics to Innertube.js 2022-02-05 19:16:36 -03:00
LuanRT
339a01f3a9 chore(release): v1.3.0 2022-02-05 18:45:30 -03:00
LuanRT
dd3f4c0009 chore: format code & other minor changes 2022-02-05 18:32:25 -03:00
LuanRT
7cd41e1d8a docs: add YouTube Music examples 2022-02-05 18:31:28 -03:00
LuanRT
6ac8561af2 feat: add lyrics support 2022-02-05 18:30:21 -03:00
LuanRT
b4607d531f fix(OAuth): secret not found due to bad regex 2022-02-04 15:20:35 -03:00
LuanRT
b3a1cdc1cd chore: remove ntoken 'translate' func var names 2022-02-03 04:54:37 -03:00
LuanRT
fd662df93d style: remove extra white space 2022-02-02 06:12:38 -03:00
LuanRT
8a1f4b4e55 Merge branch 'main' of https://github.com/LuanRT/YouTube.js 2022-02-02 06:04:49 -03:00
LuanRT
4ff83bdc3f style: add missing semi & rename some variables 2022-02-02 06:04:39 -03:00
LuanRT
c81e8e29ac chore: remove unnecessary condition 2022-02-01 18:49:17 -03:00
LuanRT
d5f884ff9b refactor: move refineNTokenData function to Utils 2022-02-01 16:47:02 -03:00
LuanRT
5517c2f202 fix: ntoken function 'translate2' not being parsed 2022-02-01 16:12:47 -03:00
LuanRT
3493a82765 chore: remove more unused variables 2022-01-31 04:31:45 -03:00
LuanRT
07f02d0dc1 chore: remove unnecessary variable 2022-01-30 20:47:26 -03:00
LuanRT
b2afa86744 chore: update deps 2022-01-30 20:43:29 -03:00
LuanRT
a1caa60750 1.2.9 2022-01-30 20:34:18 -03:00
LuanRT
e1dd718832 chore: format code and other minor changes 2022-01-30 20:28:00 -03:00
LuanRT
222bf1e61f fix: ntoken 'translate2' function not being parsed 2022-01-30 20:26:02 -03:00
LuanRT
3b48de20dd fix: oauth identity creds regex no longer working 2022-01-30 20:20:52 -03:00
LuanRT
348d901935 chore: update tests 2022-01-30 20:19:11 -03:00
LuanRT
94b12002ff feat: implement continuation requests for YTMusic 2022-01-18 15:55:07 -03:00
LuanRT
2720e8f251 chore(livechat): remove console.log 2022-01-18 15:52:35 -03:00
LuanRT
a8a1ec2182 fix (tests): video used for tests is no longer available 2022-01-18 15:50:43 -03:00
LuanRT
ee0d1bef40 deps: remove time-to-seconds dependency 2022-01-18 15:46:54 -03:00
LuanRT
5cad39ee44 fix: polling interval missing 2022-01-07 18:59:17 -03:00
LuanRT
e8ca248919 feat: add home feed support 2022-01-07 18:50:00 -03:00
LuanRT
44d09026b5 chore: simplify video details parser 2022-01-07 18:46:29 -03:00
LuanRT
ff044f4216 fix: error polling livechat due to dislikes 2022-01-07 18:45:30 -03:00
LuanRT
8153e6178c fix: subsfeed sections placeholders missing 2022-01-05 16:36:48 -03:00
LuanRT
ee3f1b4638 chore: update examples 2022-01-05 16:31:52 -03:00
LuanRT
86c8a7e0d2 fix: filter out undefined search results 2021-12-31 04:05:38 -03:00
LuanRT
b375ae2f06 chore: fix typo 2021-12-31 03:35:05 -03:00
LuanRT
2ff4b2ea95 test: remove node 12 build 2021-12-31 03:27:54 -03:00
LuanRT
599ab69107 refactor: rewrite inefficient code and add docs 2021-12-31 03:19:58 -03:00
LuanRT
c6c6dc24bd feat: add support for music search 2021-12-31 03:15:59 -03:00
LuanRT
fa2e0724c6 docs: fix a typo 2021-12-22 15:27:26 -03:00
LuanRT
6af689ada6 docs: improve documentation & add unseen notifications example 2021-12-22 15:22:10 -03:00
LuanRT
9997c0d939 build (package): release v1.2.8 2021-12-18 19:14:15 -03:00
LuanRT
3dee7fc12f fix: forgot to export getVideoInfo :v 2021-12-18 12:20:17 -03:00
LuanRT
4dff129b74 chore: update tests 2021-12-18 12:17:00 -03:00
LuanRT
7e86bb15e0 refactor (OAuth): a simpler & more efficient auth system 2021-12-18 12:03:44 -03:00
LuanRT
d0de164722 chore: update examples & format code 2021-12-18 00:24:57 -03:00
LuanRT
5d165ebb61 refactor: move all internal actions to Actions.js for better maintainability 2021-12-18 00:16:47 -03:00
LuanRT
2ad19adbe4 refactor: move search request code to Actions.js for better maintainability & organization 2021-12-17 23:55:39 -03:00
LuanRT
cabbdc9f50 chore: encode search filters correctly 2021-12-17 23:08:35 -03:00
LuanRT
fe84f31432 chore: add search filter protobuf message 2021-12-17 21:12:14 -03:00
LuanRT
22c605f528 perf (OAuth): check access token validity in a more efficient way 2021-12-13 21:58:02 -03:00
LuanRT
6777b59116 feat: include available stream quality in the metadata 2021-12-13 21:38:31 -03:00
stranothus
de70d851d8 Desktop version compatible
The desktop version is sent a different resopnse by the Innertube API
and streamingData needs to be accessed from data, rather than the third
index of data and through playerResponse.
2021-12-13 15:40:44 -06:00
stranothus
e20e671d16 Include available video qualities to metadata
The playerResponse streamingData adaptiveFormats are filter to include only those which
include a qualityLabel. This array is then mapped to an array of qualityLabels and sorted
from lowest to highest quality.
2021-12-13 09:29:45 -06:00
LuanRT
d0e1140029 chore: yes, more code formatting 2021-12-09 23:24:50 -03:00
LuanRT
bf483256fe chore: remove useless comments & format code 2021-12-09 22:45:18 -03:00
LuanRT
d4c32d47e1 build (package): release v1.2.7 2021-11-24 12:14:46 -03:00
LuanRT
70feab80da fix: check if dislike count is available to avoid unexpected errors 2021-11-23 07:17:17 -03:00
LuanRT
c006f49dc1 chore: remove unnecessary param 2021-11-23 06:09:12 -03:00
LuanRT
aeff0c3fdc build (package): increment version 2021-11-19 13:50:50 -03:00
LuanRT
00d67ed417 chore (OAuth): better & simpler regular expression 2021-11-19 13:29:02 -03:00
LuanRT
78f93c7118 fix: add “g” flag so it matches all possible strings 2021-11-19 13:27:07 -03:00
LuanRT
6db3f0ad91 fix: download not possible due to visitorData being undefined 2021-11-14 12:22:56 -03:00
UnbreakCode
cf48385f72 fixed x-goog-visitor-id for downloader 2021-11-14 15:46:21 +01:00
LuanRT
e70eab2416 build (package): increment version 2021-11-13 01:18:17 -03:00
LuanRT
771c6050c4 fix (n-token): yet again YouTube added new functions that do exactly the same thing as before but in a different way 2021-11-13 01:04:03 -03:00
LuanRT
5670228a4f docs: remove licence scan badge, it's not really necessary for this project 2021-11-05 09:12:47 -03:00
LuanRT
62ae384f27 docs: add license scan report and status 2021-11-05 08:42:15 -03:00
fossabot
185cdbd6ce Add license scan report and status
Signed off by: fossabot <badges@fossa.com>
2021-11-05 04:27:51 -07:00
LuanRT
5dd6ef9e24 fix: don't limit range end 2021-11-04 08:01:03 -03:00
LuanRT
309942090d docs: add info event response example 2021-11-04 04:27:35 -03:00
LuanRT
af4a4b8b82 . 2021-11-04 04:13:50 -03:00
LuanRT
b9c9d40077 feat: add support for custom data range 2021-11-04 04:05:16 -03:00
LuanRT
62fbc166c5 chore: format code 2021-11-03 23:34:58 -03:00
LuanRT
c3991dda32 fix (OAuth): forgot to change some variables to uppercase 2021-11-03 23:33:34 -03:00
LuanRT
95e804e8ea docs: fix typos 2021-11-03 21:41:55 -03:00
LuanRT
67a8435421 tests: throw an error if one or more tests fail 2021-11-02 16:41:45 -03:00
LuanRT
1847558d50 test: rename some vars & fix typos 2021-11-02 08:45:18 -03:00
LuanRT
bde915bce3 chore: remove comment on workflow file 2021-11-02 08:03:04 -03:00
LuanRT
a9ad3a31b5 chore: update workflow 2021-11-02 08:01:31 -03:00
LuanRT
e52e6138bd chore: format & fix typos 2021-11-02 07:57:33 -03:00
LuanRT
76248ad143 build (package): update test script 2021-11-02 07:51:36 -03:00
LuanRT
94f441a4e2 chore: format code 2021-11-02 07:45:16 -03:00
LuanRT
685e14fcc1 perf: move to object literal and simplify transformation functions 2021-11-02 07:40:31 -03:00
LuanRT
3cd115461f refactor: change constants to uppercase and refactor some code 2021-11-02 07:37:28 -03:00
LuanRT
6da4ee8fd4 chore: add tests 2021-11-02 07:34:47 -03:00
LuanRT
b095044baa build (package): increment version 2021-10-29 22:22:53 -03:00
LuanRT
ba2b757fdb fix (OAuth): remove any new lines so the client identity can be found more easily 2021-10-29 22:10:43 -03:00
LuanRT
9d7d0d83e1 fix: catch any possible errors when transforming the n token 2021-10-28 22:52:43 -03:00
LuanRT
b893e46634 chore: fix typo 2021-10-25 18:14:35 -03:00
LuanRT
d8ab6f3887 fix: handle errors when initializing 2021-10-25 18:04:28 -03:00
LuanRT
eea5ebfd04 fix: handle errors when initializing 2021-10-25 18:03:57 -03:00
LuanRT
b2117f11b9 chore: add comments and format code 2021-10-25 18:02:28 -03:00
18 changed files with 1994 additions and 893 deletions

View File

@@ -1,6 +1,5 @@
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
# TODO: Make actual tests instead of just running the examples file
name: Build
@@ -17,7 +16,7 @@ jobs:
strategy:
matrix:
node-version: [ 12.x, 14.x, 15.x]
node-version: [ 14.x, 15.x, 16.x ]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
@@ -27,4 +26,4 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
- run: npm install
- run: node ./examples
- run: npm test

779
README.md
View File

@@ -1,24 +1,30 @@
# 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>
[![Build](https://github.com/LuanRT/YouTube.js/actions/workflows/node.js.yml/badge.svg)](https://github.com/LuanRT/YouTube.js/actions/workflows/node.js.yml)
[![NPM](https://img.shields.io/npm/v/youtubei.js?color=%2335C757)](https://www.npmjs.com/package/youtubei.js)
[![CodeFactor](https://www.codefactor.io/repository/github/luanrt/youtube.js/badge)](https://www.codefactor.io/repository/github/luanrt/youtube.js)
<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, here's a short summary of what it can do:
As of now, this is one of the most advanced & stable YouTube libraries out there, here's a short summary of its features:
- Search videos
- Get detailed info about any video
- Fetch live chat & live stats in real time
- Fetch notifications
- Fetch subscriptions feed
- Change notifications preferences for a channel
- Subscribe/Unsubscribe/Like/Dislike/Comment
- Easily sign into your account in an easy & reliable way.
- Last but not least, you can also download videos!
- Get notifications
- Get subscriptions/home 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.
@@ -34,41 +40,37 @@ npm install youtubei.js
## Usage
[1. Basic Usage](https://github.com/LuanRT/YouTube.js#usage)
[1. Basic Usage](#usage)
[2. Interactions](https://github.com/LuanRT/YouTube.js#interactions)
[2. Interactions](#interactions)
[3. Fetching live chats](https://github.com/LuanRT/YouTube.js#fetching-live-chats)
[3. Live chats](#fetching-live-chats)
[4. Downloading videos](https://github.com/LuanRT/YouTube.js#downloading-videos)
[4. Downloading videos](#downloading-videos)
[5. Signing-in](https://github.com/LuanRT/YouTube.js#signing-in)
[5. Signing-in](#signing-in)
[6. Disclaimer](https://github.com/LuanRT/YouTube.js#disclaimer)
[6. Disclaimer](#disclaimer)
First of all we're gonna start by initializing the Innertube class:
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
// YouTube:
const search = await youtube.search('Looking for life on Mars - Documentary');
// YTMusic:
const search = await youtube.search('Never Gonna Give You Up', { client: 'YTMUSIC' });
```
<details>
<summary>Output</summary>
<summary>YT Search Output</summary>
<p>
```js
@@ -126,9 +128,233 @@ const search = await youtube.search('Looking for life on Mars - Documentary');
</p>
</details>
<details>
<summary>YTMusic Search Output</summary>
<p>
Get details about a specific video:
```js
{
"songs":[
{
"id":"AhCWdFBd_fM",
"title":"Never Gonna Give You Up",
"artist":"Barry White",
"album":"Barry White - His Greatest Hits",
"duration":"4:50",
"thumbnail":{
"thumbnails":[
{
"url":"https://lh3.googleusercontent.com/S69XTGGI63L8rJJlCx7rJgp0decIo3EdDKGWR_y8B3SE9fEYp-VGu3ygZ3DrlS3tTTyqN8CUfpajjsA=w60-h60-l90-rj",
"width":60,
"height":60
},
{
"url":"https://lh3.googleusercontent.com/S69XTGGI63L8rJJlCx7rJgp0decIo3EdDKGWR_y8B3SE9fEYp-VGu3ygZ3DrlS3tTTyqN8CUfpajjsA=w120-h120-l90-rj",
"width":120,
"height":120
}
]
}
},
{
"id":"C5ITdcx-wRA",
"title":"Never Gonna Give You Up",
"artist":"Home Free",
"album":"Never Gonna Give You Up",
"duration":"3:03",
"thumbnail":{
"thumbnails":[
{
"url":"https://lh3.googleusercontent.com/isnABE6M8Dzq5WG7ZrJRGdKVmF8M8xJDJTM6aMf7d0C-XpqGIHFd1n7i6lgZM_K7LbHXuEuRaXhfnqc=w60-h60-l90-rj",
"width":60,
"height":60
},
{
"url":"https://lh3.googleusercontent.com/isnABE6M8Dzq5WG7ZrJRGdKVmF8M8xJDJTM6aMf7d0C-XpqGIHFd1n7i6lgZM_K7LbHXuEuRaXhfnqc=w120-h120-l90-rj",
"width":120,
"height":120
}
]
}
},
{
"id":"XxWzZxuo6Vk",
"title":"Never Gonna Give You Up",
"artist":"Barry White",
"album":"Dinah Washington",
"duration":"The Unforgettable Voices: 30 Best Of Dinah Washington & Barry White",
"thumbnail":{
"thumbnails":[
{
"url":"https://lh3.googleusercontent.com/KXnlog1Me9ONfcdtT1O4fHr2uRqYrhH1cHWksFmzNpJwzX8lNr_S5TlQsE8ICtihijkGCLp9y8ypI7BiOA=w60-h60-l90-rj",
"width":60,
"height":60
},
{
"url":"https://lh3.googleusercontent.com/KXnlog1Me9ONfcdtT1O4fHr2uRqYrhH1cHWksFmzNpJwzX8lNr_S5TlQsE8ICtihijkGCLp9y8ypI7BiOA=w120-h120-l90-rj",
"width":120,
"height":120
}
]
}
}
],
"videos":[
{
"id":"dMbZfdrzILA",
"title":"Rick Astley - Never Gonna Give You Up • TopPop",
"author":"TopPop",
"views":"313K views",
"duration":"3:25",
"thumbnail":{
"thumbnails":[
{
"url":"https://i.ytimg.com/vi/dMbZfdrzILA/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kLVbR-3q7t0AIjayar6gbsUe-OoQ",
"width":400,
"height":225
}
]
}
},
{
"id":"IO9XlQrEt2Y",
"title":"Never Gonna Give You Up",
"author":"Rick Astley",
"views":"7.1M views",
"duration":"2:58",
"thumbnail":{
"thumbnails":[
{
"url":"https://i.ytimg.com/vi/IO9XlQrEt2Y/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mqRLVI-_NB9-t7Qy-j2J030ejrKA",
"width":400,
"height":225
}
]
}
},
{
"id":"a9WHZ5M8I8w",
"title":"Rick Astley - Never Gonna Give You Up - Festival de Viña del Mar 2016 HD",
"author":"FESTIVALDEVINACHILE",
"views":"2.8M views",
"duration":"8:13",
"thumbnail":{
"thumbnails":[
{
"url":"https://i.ytimg.com/vi/a9WHZ5M8I8w/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mJDPxemHrcREbbK-9uBM3SIRm-Og",
"width":400,
"height":225
}
]
}
}
],
"albums":[
{
"title":"Never Gonna Give You Up",
"author":"Royal Reggie",
"year":"2022",
"thumbnail":{
"thumbnails":[
{
"url":"https://lh3.googleusercontent.com/DeMUgtcPxCOye_JYJaywQjuJHZjdzSYqU-231p8ZADV1EmPC5w3poMQRfm3_U83wLQR0nUbXnJ_Ujyq8=w60-h60-l90-rj",
"width":60,
"height":60
},
{
"url":"https://lh3.googleusercontent.com/DeMUgtcPxCOye_JYJaywQjuJHZjdzSYqU-231p8ZADV1EmPC5w3poMQRfm3_U83wLQR0nUbXnJ_Ujyq8=w120-h120-l90-rj",
"width":120,
"height":120
},
{
"url":"https://lh3.googleusercontent.com/DeMUgtcPxCOye_JYJaywQjuJHZjdzSYqU-231p8ZADV1EmPC5w3poMQRfm3_U83wLQR0nUbXnJ_Ujyq8=w226-h226-l90-rj",
"width":226,
"height":226
},
{
"url":"https://lh3.googleusercontent.com/DeMUgtcPxCOye_JYJaywQjuJHZjdzSYqU-231p8ZADV1EmPC5w3poMQRfm3_U83wLQR0nUbXnJ_Ujyq8=w544-h544-l90-rj",
"width":544,
"height":544
}
]
}
},
{
"title":"Never Gonna Give You Up",
"author":"Carlos Marin",
"year":"2020",
"thumbnail":{
"thumbnails":[
{
"url":"https://lh3.googleusercontent.com/i6JslNpviVqPZfLCBvLvmDyscy8ZQ2CShRz38hGj-PUz_H4wLTR0gkKU6tQoREDoARwKBl1krytJdBQ=w60-h60-l90-rj",
"width":60,
"height":60
},
{
"url":"https://lh3.googleusercontent.com/i6JslNpviVqPZfLCBvLvmDyscy8ZQ2CShRz38hGj-PUz_H4wLTR0gkKU6tQoREDoARwKBl1krytJdBQ=w120-h120-l90-rj",
"width":120,
"height":120
},
{
"url":"https://lh3.googleusercontent.com/i6JslNpviVqPZfLCBvLvmDyscy8ZQ2CShRz38hGj-PUz_H4wLTR0gkKU6tQoREDoARwKBl1krytJdBQ=w226-h226-l90-rj",
"width":226,
"height":226
},
{
"url":"https://lh3.googleusercontent.com/i6JslNpviVqPZfLCBvLvmDyscy8ZQ2CShRz38hGj-PUz_H4wLTR0gkKU6tQoREDoARwKBl1krytJdBQ=w544-h544-l90-rj",
"width":544,
"height":544
}
]
}
},
{
"title":"Never Gonna Give You Up",
"author":"Magnus Carlsson",
"year":"2021",
"thumbnail":{
"thumbnails":[
{
"url":"https://lh3.googleusercontent.com/43cP--d3Eqj3L5FeBbYFkdsnisf7nYb5-Rq4yZDf1kvH6_pEk4RW4Nd004L4ZwG6VzzT4JzFDVPS-8w=w60-h60-l90-rj",
"width":60,
"height":60
},
{
"url":"https://lh3.googleusercontent.com/43cP--d3Eqj3L5FeBbYFkdsnisf7nYb5-Rq4yZDf1kvH6_pEk4RW4Nd004L4ZwG6VzzT4JzFDVPS-8w=w120-h120-l90-rj",
"width":120,
"height":120
},
{
"url":"https://lh3.googleusercontent.com/43cP--d3Eqj3L5FeBbYFkdsnisf7nYb5-Rq4yZDf1kvH6_pEk4RW4Nd004L4ZwG6VzzT4JzFDVPS-8w=w226-h226-l90-rj",
"width":226,
"height":226
},
{
"url":"https://lh3.googleusercontent.com/43cP--d3Eqj3L5FeBbYFkdsnisf7nYb5-Rq4yZDf1kvH6_pEk4RW4Nd004L4ZwG6VzzT4JzFDVPS-8w=w544-h544-l90-rj",
"width":544,
"height":544
}
]
}
}
]
}
```
</p>
</details>
<br>
Find lyrics for a song, directly from YTMusic!
```js
const search = await youtube.search('7 Years', { client: 'YTMUSIC' });
const lyrics = await search.songs[0].getLyrics();
// Or:
const lyrics = await youtube.getLyrics(search.songs[0].id);
```
Get video details:
```js
const video = await youtube.getDetails(search.videos[0].id);
@@ -278,9 +504,442 @@ const comments_continuation = await comments.getContinuation();
</p>
</details>
Get home feed:
```js
const home_feed = await youtube.getHomeFeed();
```
<details>
<summary>Output</summary>
<p>
```js
[
{
"id":"TVcKu_c1C7E",
"title":"The Things We Don't Know About the Universe",
"channel":"Sciencephile the AI",
"metadata":{
"view_count":"233,835 views",
"thumbnail":{
"url":"https://i.ytimg.com/vi/TVcKu_c1C7E/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLByf2X2pKs1wbWyLIx-FnXFt1PVZw",
"width":720,
"height":404
},
"moving_thumbnail":{
"url":"https://i.ytimg.com/an_webp/TVcKu_c1C7E/mqdefault_6s.webp?du=3000&sqp=COGe-48G&rs=AOn4CLDslQ2RYSgaF3aEY8pcuyIzvfLJbQ",
"width":320,
"height":180
},
"published":"3 months ago",
"badges":[
],
"owner_badges":[
"Verified"
]
}
},
{
"id":"1Tdx9ZZuf_w",
"title":"Life is Strange: Before The Storm Remastered Episode 1 Gameplay Walkthrough",
"channel":"DomTheBomb",
"metadata":{
"view_count":"N/A",
"thumbnail":{
"url":"https://i.ytimg.com/vi/1Tdx9ZZuf_w/hq720_live.jpg?sqp=CMzD-48G-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLC9N4GPouauc-Jw3Y69uCXWVEfBiQ",
"width":720,
"height":404
},
"moving_thumbnail":[
],
"published":"N/A",
"badges":[
"LIVE NOW"
],
"owner_badges":[
"Verified"
]
}
},
{
"id":"C776bXWlgSk",
"title":"Probably my Most BRUTAL BIRDEATER TARANTULA ever .",
"channel":"Exotics Lair",
"metadata":{
"view_count":"346,246 views",
"thumbnail":{
"url":"https://i.ytimg.com/vi/C776bXWlgSk/hqdefault.jpg?sqp=-oaymwEcCOADEI4CSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDgWQrLwmFTG-KzLUYRdR9KFA5bDw",
"width":480,
"height":270
},
"moving_thumbnail":{
"url":"https://i.ytimg.com/an_webp/C776bXWlgSk/mqdefault_6s.webp?du=3000&sqp=COal-48G&rs=AOn4CLAfsyW-NUXvgFqAHBJr_3pJDZlfhA",
"width":320,
"height":180
},
"published":"5 months ago",
"badges":[
],
"owner_badges":[
"Verified"
]
}
},
{
"id":"nrvnpFCcZeA",
"title":"Run Windows 11 on phone! And play PC games?!!!",
"channel":"Geekerwan",
"metadata":{
"view_count":"519,217 views",
"thumbnail":{
"url":"https://i.ytimg.com/vi/nrvnpFCcZeA/hqdefault.jpg?sqp=-oaymwEcCOADEI4CSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLA0ny_9_K9iLL_u7grHZ4PoAJkH4Q",
"width":480,
"height":270
},
"moving_thumbnail":{
"url":"https://i.ytimg.com/an_webp/nrvnpFCcZeA/mqdefault_6s.webp?du=3000&sqp=CKCR-48G&rs=AOn4CLB9atuuBjdMCWTHgP-v4N75agxvPA",
"width":320,
"height":180
},
"published":"1 month ago",
"badges":[
],
"owner_badges":[
]
}
},
{
"id":"owtvGIQDdiU",
"title":"This Is What The Clouds Of Titan Sound Like! Huygens Probe Sound Recording 2005 (4K UHD)",
"channel":"V101 Science",
"metadata":{
"view_count":"744,350 views",
"thumbnail":{
"url":"https://i.ytimg.com/vi/owtvGIQDdiU/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDbQLvaaOO3jW9bGY1SaQAdxIUJOQ",
"width":720,
"height":404
},
"moving_thumbnail":{
"url":"https://i.ytimg.com/an_webp/owtvGIQDdiU/mqdefault_6s.webp?du=3000&sqp=CImy-48G&rs=AOn4CLDXD3UPQ6iix8orhjJs3yDPpJlckQ",
"width":320,
"height":180
},
"published":"1 year ago",
"badges":[
],
"owner_badges":[
"Verified"
]
}
},
{
"id":"pI7ytZ1oyY4",
"title":"My Pet Praying Mantis Passed Away. Goodbye, Kiwi..",
"channel":"제발돼라 PleaseBee",
"metadata":{
"view_count":"3,311,251 views",
"thumbnail":{
"url":"https://i.ytimg.com/vi/pI7ytZ1oyY4/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDisjK7ZecKov3aFUsE-Xe5x92VEQ",
"width":720,
"height":404
},
"moving_thumbnail":{
"url":"https://i.ytimg.com/an_webp/pI7ytZ1oyY4/mqdefault_6s.webp?du=3000&sqp=CKbE-48G&rs=AOn4CLD_wWCno1Q6EqhLYp7kMkCPc0y0dg",
"width":320,
"height":180
},
"published":"3 weeks ago",
"badges":[
],
"owner_badges":[
]
}
},
{
"id":"dXdoim96v5A",
"title":"8-bit CPU control logic: Part 1",
"channel":"Ben Eater",
"metadata":{
"view_count":"718,780 views",
"thumbnail":{
"url":"https://i.ytimg.com/vi/dXdoim96v5A/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCBSzOjvO-0645gRSb8WezGKYSvUg",
"width":720,
"height":404
},
"moving_thumbnail":{
"url":"https://i.ytimg.com/an_webp/dXdoim96v5A/mqdefault_6s.webp?du=3000&sqp=CKCO-48G&rs=AOn4CLArMCkZu073j8k_QRHCm_4IuB2Uyw",
"width":320,
"height":180
},
"published":"4 years ago",
"badges":[
],
"owner_badges":[
"Verified"
]
}
},
{
"id":"AawLM81gIHo",
"title":"playlist to study like a medieval philosopher having the truth revealed by divine grace",
"channel":"Filosofia Acadêmica",
"metadata":{
"view_count":"3,182,206 views",
"thumbnail":{
"url":"https://i.ytimg.com/vi/AawLM81gIHo/hqdefault.jpg?sqp=-oaymwEcCOADEI4CSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLA_6jX1WlJ8JgjdzJ4vBdmajwkZmA",
"width":480,
"height":270
},
"moving_thumbnail":[
],
"published":"4 months ago",
"badges":[
],
"owner_badges":[
]
}
},
{
"id":"ILyQ4A3ZYu8",
"title":"LIGHTWEIGHT Mirrorless Macro Photography w/ 7Artisans 60mm f/2.8 mkII macro",
"channel":"Thomas Shahan",
"metadata":{
"view_count":"26,594 views",
"thumbnail":{
"url":"https://i.ytimg.com/vi/ILyQ4A3ZYu8/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAYn-kG_hpBdu0BZ0QLeb9EdnkXfg",
"width":720,
"height":404
},
"moving_thumbnail":{
"url":"https://i.ytimg.com/an_webp/ILyQ4A3ZYu8/mqdefault_6s.webp?du=3000&sqp=CMit-48G&rs=AOn4CLBdzzV12LamKVEyPdPUXeHqLwqOzg",
"width":320,
"height":180
},
"published":"5 months ago",
"badges":[
],
"owner_badges":[
]
}
},
{
"id":"8E2l4K0OvMc",
"title":"Linkin Park & Eminem - Soldiers (2021) Official Music Video",
"channel":"zwieR.Z.",
"metadata":{
"view_count":"2,949,402 views",
"thumbnail":{
"url":"https://i.ytimg.com/vi/8E2l4K0OvMc/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAqJt07Iwc5IFmUv-HazVO9OZWzFw",
"width":720,
"height":404
},
"moving_thumbnail":[
],
"published":"1 month ago",
"badges":[
],
"owner_badges":[
"Verified"
]
}
},
{
"id":"XFqn3uy238E",
"title":"...And We'll Do it Again",
"channel":"Kurzgesagt In a Nutshell",
"metadata":{
"view_count":"8,782,886 views",
"thumbnail":{
"url":"https://i.ytimg.com/vi/XFqn3uy238E/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLATvhGBLlbbTmMV4aDwmsBRqlh3rQ",
"width":720,
"height":404
},
"moving_thumbnail":{
"url":"https://i.ytimg.com/an_webp/XFqn3uy238E/mqdefault_6s.webp?du=3000&sqp=COGu-48G&rs=AOn4CLCghw3iwEHMhpOZU2Sd79eQAUZg9g",
"width":320,
"height":180
},
"published":"1 month ago",
"badges":[
],
"owner_badges":[
"Verified"
]
}
},
{
"id":"Ks7mmrer37g",
"title":"Using 5000 Minecarts to LAG a Pay-To-Win Server in Minecraft - Part 1",
"channel":"The Horizon",
"metadata":{
"view_count":"3,351,437 views",
"thumbnail":{
"url":"https://i.ytimg.com/vi/Ks7mmrer37g/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLA16Zi1QZ5YACpDMM0We5k5C6Cc0w",
"width":720,
"height":404
},
"moving_thumbnail":{
"url":"https://i.ytimg.com/an_webp/Ks7mmrer37g/mqdefault_6s.webp?du=3000&sqp=COip-48G&rs=AOn4CLDY3tU8sodjfeSM61JtfSovZIcmww",
"width":320,
"height":180
},
"published":"13 days ago",
"badges":[
],
"owner_badges":[
"Verified"
]
}
},
{
"id":"y7fMTCJaWjQ",
"title":"2004 PC Power On",
"channel":"hardrivethrutown",
"metadata":{
"view_count":"336,545 views",
"thumbnail":{
"url":"https://i.ytimg.com/vi/y7fMTCJaWjQ/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLB2GeuZo6-IUkJKl6M0VYhtAowNqQ",
"width":720,
"height":404
},
"moving_thumbnail":{
"url":"https://i.ytimg.com/an_webp/y7fMTCJaWjQ/mqdefault_6s.webp?du=3000&sqp=CKjB-48G&rs=AOn4CLAUUDj7VcH8uIysPlgcVZ7rx2AFaQ",
"width":320,
"height":180
},
"published":"1 month ago",
"badges":[
],
"owner_badges":[
]
}
},
{
"id":"oq2-RJH_3NE",
"title":"What if we replace the Moon with other Space Objects?",
"channel":"Sciencephile the AI",
"metadata":{
"view_count":"128,961 views",
"thumbnail":{
"url":"https://i.ytimg.com/vi/oq2-RJH_3NE/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBuCNxw_LAFkvrGfnWZ5rI_fI2cuw",
"width":720,
"height":404
},
"moving_thumbnail":{
"url":"https://i.ytimg.com/an_webp/oq2-RJH_3NE/mqdefault_6s.webp?du=3000&sqp=CNSk-48G&rs=AOn4CLAKV2gb8ZMH6_pEGroEC6FIZmyTjg",
"width":320,
"height":180
},
"published":"7 days ago",
"badges":[
],
"owner_badges":[
"Verified"
]
}
},
{
"id":"pB-nGyqJMy0",
"title":"for 4 years I thought PISSY was a boy ~ Maybe thats why SHEs always PISSED at me !!!",
"channel":"Exotics Lair",
"metadata":{
"view_count":"974,222 views",
"thumbnail":{
"url":"https://i.ytimg.com/vi/pB-nGyqJMy0/hqdefault.jpg?sqp=-oaymwEcCOADEI4CSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAf7vp-L054e_PSMrs5z_GSoIKC1w",
"width":480,
"height":270
},
"moving_thumbnail":{
"url":"https://i.ytimg.com/an_webp/pB-nGyqJMy0/mqdefault_6s.webp?du=3000&sqp=CMqL-48G&rs=AOn4CLC-yzDWXwJo45NudEmzQBMQ_8nsNg",
"width":320,
"height":180
},
"published":"1 year ago",
"badges":[
],
"owner_badges":[
"Verified"
]
}
},
{
"id":"RW4s2WtwoUY",
"title":"This Is Why Crabs Hate Stingrays",
"channel":"WATOP",
"metadata":{
"view_count":"4,329,223 views",
"thumbnail":{
"url":"https://i.ytimg.com/vi/RW4s2WtwoUY/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLD5iz1haM6niPfCZxeZFfublo_tPQ",
"width":720,
"height":404
},
"moving_thumbnail":{
"url":"https://i.ytimg.com/an_webp/RW4s2WtwoUY/mqdefault_6s.webp?du=3000&sqp=CMS1-48G&rs=AOn4CLBkPjwEFtz6e6iNOpB13tWcuik4pw",
"width":320,
"height":180
},
"published":"8 months ago",
"badges":[
],
"owner_badges":[
"Verified"
]
}
},
{
"id":"0wj8ueoUjt8",
"title":"RagnBone Man & Calvin Harris - Giant (Live at BRITs 2019)",
"channel":"Rag'n'Bone Man",
"metadata":{
"view_count":"5,636,727 views",
"thumbnail":{
"url":"https://i.ytimg.com/vi/0wj8ueoUjt8/hq720.jpg?sqp=-oaymwEcCNAFEJQDSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDgmKnz4ilXGAdfU3zBBhZVPe9ERw",
"width":720,
"height":404
},
"moving_thumbnail":[
],
"published":"2 years ago",
"badges":[
],
"owner_badges":[
"Official Artist Channel"
]
}
}
]
```
</p>
</details>
Get subscriptions feed:
```js
const mysubfeed = await youtube.getSubscriptionsFeed();
const mysubsfeed = await youtube.getSubscriptionsFeed();
```
<details>
@@ -489,13 +1148,19 @@ const notifications = await youtube.getNotifications();
</p>
</details>
Get unseen notifications count:
```js
const notifications = await youtube.getUnseenNotificationsCount();
```
### Interactions:
---
* 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();
@@ -503,7 +1168,7 @@ await video.unsubscribe();
* 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();
@@ -519,7 +1184,9 @@ await video.comment('Haha, nice!');
* Change notification preferences:
```js
const video = await youtube.getDetails(VIDEO_ID_HERE);
await video.setNotificationPref('ALL'); // ALL | NONE | PERSONALIZED
// Options: ALL | NONE | PERSONALIZED
await video.setNotificationPref('ALL');
```
All of the above interactions will return ```{ success: true, status_code: 200 }``` if everything goes alright.
@@ -533,10 +1200,9 @@ const Innertube = require('youtubei.js');
async function start() {
const youtube = await new Innertube();
const search = await youtube.search('Some random live');
const search = await youtube.search('Some random livestream');
const video = await youtube.getDetails(search.videos[0].id);
// This should only be called if you're sure it's a live and that it's still ongoing
const livechat = video.getLivechat();
// Updated stats about the livestream
@@ -561,9 +1227,9 @@ Stop fetching the live chat:
livechat.stop();
```
Deleting a message:
Delete a message:
```js
const msg = await livechat.sendMessage('Nice live!');
const msg = await livechat.sendMessage('Nice livestream!');
await msg.deleteMessage();
```
@@ -578,10 +1244,12 @@ 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
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”
});
@@ -592,6 +1260,7 @@ async function start() {
});
stream.on('info', (info) => {
// { video_details: {..}, selected_format: {..}, formats: {..} }
console.info('[DOWNLOADER]', `Downloading ${info.video_details.title} by ${info.video_details.metadata.channel_name}`);
});
@@ -613,6 +1282,16 @@ async function start() {
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();
@@ -621,10 +1300,10 @@ stream.cancel();
### Signing-in:
---
This library allows you to sign-in in two different ways:
When signing in to your account, you have two options:
- Using OAuth 2.0, easy, simple & reliable.
- Cookies, usually more complicated to get and unreliable.
- Use OAuth 2.0; easy, simple & reliable.
- Cookies; usually more complicated to get and unreliable.
OAuth:
@@ -637,19 +1316,17 @@ 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);
});
@@ -674,13 +1351,11 @@ async function start() {
start();
```
## Note
Never sign-in with your personal account, you might get banned if you spam (don't ever do that) or simply because YouTube detected unusual activity coming from your account. Also, I'm not responsible if any of that happens to you.
## 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.

View File

@@ -2,23 +2,34 @@
const fs = require('fs');
const Innertube = require('..');
const creds_path = './yt_oauth_creds.json';
const creds = fs.existsSync(creds_path) && JSON.parse(fs.readFileSync(creds_path).toString()) || {};
async function start() {
const youtube = await new Innertube();
youtube.ev.on('auth', (data) => {
if (data.status === 'AUTHORIZATION_PENDING') {
console.info(`Hello!\nOn your phone or computer, go to ${data.verification_url} and enter the code ${data.code}`);
} else if (data.status === 'SUCCESS') {
fs.writeFileSync(creds_path, JSON.stringify(data.credentials));
console.info('Successfully signed-in, enjoy!');
}
});
youtube.ev.on('update-credentials', (data) => {
fs.writeFileSync(creds_path, JSON.stringify(data.credentials));
console.info('Credentials updated!', data);
});
await youtube.signIn(creds);
// Searching, getting details about videos & making interactions:
const search = await youtube.search('Looking for life on Mars - documentary');
console.info('Search results:', search);
if (search.videos.length === 0)
return console.error('Could not find any video about that on YouTube.');
const video = await youtube.getDetails(search.videos[0].id).catch((error) => error);
console.info('Video details:', video);
if (video instanceof Error)
return console.error('Could not get details for ' + search.videos[0].title);
if (youtube.logged_in) {
const myNotifications = await youtube.getNotifications();
console.info('My notifications:', myNotifications);

View File

@@ -1,13 +1,22 @@
'use strict';
const Uuid = require('uuid');
const Axios = require('axios');
const Utils = require('./Utils');
const Constants = require('./Constants');
const Uuid = require('uuid');
/**
* 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 logged in');
let data = {};
if (!session.logged_in) throw new Error('You are not signed-in');
let data;
switch (engagement_type) {
case 'like/like':
case 'like/dislike':
@@ -30,35 +39,68 @@ async function engage(session, engagement_type, args = {}) {
data = {
context: session.context,
commentText: args.text,
createCommentParams: Utils.generateCommentParams(args.video_id)
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_request_opts({ session, id: args.video_id, data })).catch((error) => error);
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/${engagement_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, id: args.video_id, data })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status
};
}
async function browse(session, action_type) {
if (!session.logged_in) throw new Error('You are not logged in');
/**
* 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 && action_type != 'lyrics') throw new Error('You are not signed-in');
let data;
switch (action_type) {
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;
case 'lyrics':
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,
browseId: args.browse_id
}
break;
default:
}
const response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/browse${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session })).catch((error) => error);
const client_domain = args.ytmusic && Constants.URLS.YT_MUSIC_URL || Constants.URLS.YT_BASE_URL;
const response = await Axios.post(`${client_domain}/youtubei/v1/browse${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, ytmusic: args.ytmusic })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
@@ -66,9 +108,67 @@ async function browse(session, action_type) {
};
}
async function notifications(session, action_type, args = {}) {
if (!session.logged_in) throw new Error('You are not logged in');
/**
* 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 };
@@ -91,9 +191,11 @@ async function notifications(session, action_type, args = {}) {
default:
}
const response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/notification/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session })).catch((error) => error);
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,
@@ -101,14 +203,29 @@ async function notifications(session, action_type, args = {}) {
};
}
/**
* Interacts with YouTube's livechat system.
*
* @param {object} session A valid Innertube session.
* @param {string} action_type Type of action.
* @param {object} args Action arguments.
* @returns {object} { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string }
*/
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.generateMessageParams(args.channel_id, args.video_id),
clientMessageId: `INntLiB${Uuid.v4()}`,
params: Utils.encodeMessageParams(args.channel_id, args.video_id),
clientMessageId: `ytjs-${Uuid.v4()}`,
richMessage: {
textSegments: [{ text: args.text }]
}
@@ -125,39 +242,82 @@ async function livechat(session, action_type, args = {}) {
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_request_opts({ session, params: args.params })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
data: response.data
};
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 };
}
async function getContinuation(session, info = {}) {
/**
* 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 (info.continuation_token) {
data.continuation = info.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;
}
}
if (info.video_id) {
data.videoId = info.video_id;
data.racyCheckOk = true;
data.contentCheckOk = false;
data.autonavState = 'STATE_NONE';
data.playbackContext = {
vis: 0,
lactMilliseconds: '-1'
};
data.captionsRequested = false;
}
const response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/next${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session })).catch((error) => error);
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,
@@ -165,4 +325,4 @@ async function getContinuation(session, info = {}) {
};
}
module.exports = { engage, browse, notifications, livechat, getContinuation };
module.exports = { engage, browse, search, notifications, livechat, getVideoInfo, getContinuation };

View File

@@ -2,334 +2,128 @@
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 = {
params: info.params || {},
headers: {
'accept': '*/*',
'user-agent': Utils.getRandomUserAgent(info.desktop ? 'desktop' : 'mobile').userAgent,
'content-type': 'application/json',
'accept-language': 'en-US,en;q=0.9',
'x-goog-authuser': 0,
'x-goog-visitor-id': info.session.context.client.visitorData,
'x-youtube-client-name': info.desktop ? 1 : 2,
'x-youtube-client-version': info.session.context.client.clientVersion,
'x-youtube-chrome-connected': 'source=Chrome,mode=0,enable_account_consistency=true,supervised=false,consistency_enabled_by_default=false',
'x-origin': info.desktop ? urls.YT_BASE_URL : urls.YT_MOBILE_URL,
'origin': info.desktop ? urls.YT_BASE_URL : urls.YT_MOBILE_URL,
}
};
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.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|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'
}
return headers;
};
const formatVideoData = (data, context, desktop) => {
let video_details = {};
let metadata = {};
if (desktop) {
metadata.embed = data.microformat.playerMicroformatRenderer.embed;
metadata.view_count = parseInt(data.videoDetails.viewCount);
metadata.average_rating = data.videoDetails.averageRating;
metadata.length_seconds = data.microformat.playerMicroformatRenderer.lengthSeconds;
metadata.channel_id = data.videoDetails.channelId;
metadata.channel_url = data.microformat.playerMicroformatRenderer.ownerProfileUrl;
metadata.external_channel_id = data.microformat.playerMicroformatRenderer.externalChannelId;
metadata.is_live_content = data.videoDetails.isLiveContent;
metadata.is_family_safe = data.microformat.playerMicroformatRenderer.isFamilySafe;
metadata.is_unlisted = data.microformat.playerMicroformatRenderer.isUnlisted;
metadata.is_private = data.videoDetails.isPrivate;
metadata.has_ypc_metadata = data.microformat.playerMicroformatRenderer.hasYpcMetadata;
metadata.category = data.microformat.playerMicroformatRenderer.category;
metadata.channel_name = data.microformat.playerMicroformatRenderer.ownerChannelName;
metadata.publish_date = data.microformat.playerMicroformatRenderer.publishDate || 'N/A';
metadata.upload_date = data.microformat.playerMicroformatRenderer.uploadDate || 'N/A';
metadata.keywords = data.videoDetails.keywords || [];
video_details.title = data.videoDetails.title;
video_details.description = data.videoDetails.shortDescription;
video_details.thumbnail = data.videoDetails.thumbnail.thumbnails.slice(-1)[0];
video_details.metadata = metadata;
} else {
metadata.embed = data[2].playerResponse.microformat.playerMicroformatRenderer.embed;
metadata.likes = parseInt(data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1].slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[0].slimMetadataToggleButtonRenderer.button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, ''));
metadata.dislikes = parseInt(data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1].slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[1].slimMetadataToggleButtonRenderer.button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, ''));
metadata.view_count = parseInt(data[2].playerResponse.videoDetails.viewCount);
metadata.average_rating = data[2].playerResponse.videoDetails.averageRating;
metadata.length_seconds = data[2].playerResponse.microformat.playerMicroformatRenderer.lengthSeconds;
metadata.channel_id = data[2].playerResponse.videoDetails.channelId;
metadata.channel_url = data[2].playerResponse.microformat.playerMicroformatRenderer.ownerProfileUrl;
metadata.external_channel_id = data[2].playerResponse.microformat.playerMicroformatRenderer.externalChannelId;
metadata.is_live_content = data[2].playerResponse.videoDetails.isLiveContent;
metadata.is_family_safe = data[2].playerResponse.microformat.playerMicroformatRenderer.isFamilySafe;
metadata.is_unlisted = data[2].playerResponse.microformat.playerMicroformatRenderer.isUnlisted;
metadata.is_private = data[2].playerResponse.videoDetails.isPrivate;
metadata.has_ypc_metadata = data[2].playerResponse.microformat.playerMicroformatRenderer.hasYpcMetadata;
metadata.category = data[2].playerResponse.microformat.playerMicroformatRenderer.category;
metadata.channel_name = data[2].playerResponse.microformat.playerMicroformatRenderer.ownerChannelName;
metadata.publish_date = data[2].playerResponse.microformat.playerMicroformatRenderer.publishDate;
metadata.upload_date = data[2].playerResponse.microformat.playerMicroformatRenderer.uploadDate;
metadata.keywords = data[2].playerResponse.videoDetails.keywords;
video_details.title = data[2].playerResponse.videoDetails.title;
video_details.description = data[2].playerResponse.videoDetails.shortDescription;
video_details.thumbnail = data[2].playerResponse.videoDetails.thumbnail.thumbnails.slice(-1)[0];
// Functions
video_details.like = () => {};
video_details.dislike = () => {};
video_details.removeLike = () => {};
video_details.subscribe = () => {};
video_details.unsubscribe = () => {};
video_details.comment = () => {};
video_details.getComments = () => {};
video_details.setNotificationPref = () => {};
video_details.getLivechat = () => {};
// Additional metadata
video_details.metadata = metadata;
}
return video_details;
};
const base64_alphabet = {
normal: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'.split(''),
reverse: '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'.split('')
};
const filters = (order) => {
// TODO: Refactor this with protobuf encoding
switch (order) {
case 'any,any,relevance':
return 'EgIQAQ%3D%3D';
case 'hour,any,relevance':
return 'EgIIAQ%3D%3D';
case 'day,any,relevance':
return 'EgQIAhAB';
case 'week,any,relevance':
return 'EgQIAxAB';
case 'month,any,relevance':
return 'EgQIBBAB';
case 'year,any,relevance':
return 'EgQIBRAB';
case 'any,short,relevance':
return 'EgQQARgB';
case 'hour,short,relevance':
return 'EgYIARABGAE%3D';
case 'day,short,relevance':
return 'EgYIAhABGAE%3D';
case 'week,short,relevance':
return 'EgYIAxABGAE%3D';
case 'month,short,relevance':
return 'EgYIBBABGAE%3D';
case 'year,short,relevance':
return 'EgYIBRABGAE%3D';
case 'any,long,relevance':
return 'EgQQARgC';
case 'hour,long,relevance':
return 'EgYIARABGAI%3D';
case 'day,long,relevance':
return 'EgYIAhABGAI%3D';
case 'week,long,relevance':
return 'EgYIAxABGAI%3D';
case 'month,long,relevance':
return 'EgYIBBABGAI%3D';
case 'year,long,relevance':
return 'EgYIBRABGAI%3D';
case 'any,any,age':
return 'CAISAhAB';
case 'hour,any,age':
return 'CAISBAgBEAE%3D';
case 'day,any,age':
return 'CAISBAgCEAE%3D';
case 'week,any,age':
return 'CAISBAgDEAE%3D';
case 'month,any,age':
return 'CAISBAgEEAE%3D';
case 'year,any,age':
return 'CAISBAgFEAE%3D';
case 'any,short,age':
return 'CAISBBABGAE%3D';
case 'hour,short,age':
return 'CAISBggBEAEYAQ%3D%3D';
case 'day,short,age':
return 'CAISBggCEAEYAQ%3D%3D';
case 'week,short,age':
return 'CAISBggDEAEYAQ%3D%3D';
case 'month,short,age':
return 'CAISBggEEAEYAQ%3D%3D';
case 'year,short,age':
return 'CAISBggFEAEYAQ%3D%3D';
case 'any,long,age':
return 'CAISBBABGAI%3D';
case 'hour,long,age':
return 'CAISBggBEAEYAg%3D%3D';
case 'day,long,age':
return 'CAISBggCEAEYAg%3D%3D';
case 'week,long,age':
return 'CAISBggDEAEYAg%3D%3D';
case 'month,long,age':
return 'CAISBggEEAEYAg%3D%3D';
case 'year,long,age':
return 'CAISBggFEAEYAg%3D%3D';
case 'any,any,views':
return 'CAMSAhAB';
case 'hour,any,views':
return 'CAMSBAgBEAE%3D';
case 'day,any,views':
return 'CAMSBAgCEAE%3D';
case 'week,any,views':
return 'CAMSBAgDEAE%3D';
case 'month,any,views':
return 'CAMSBAgEEAE%3D';
case 'year,any,views':
return 'CAMSBAgFEAE%3D';
case 'any,short,views':
return 'CAMSBBABGAE%3D';
case 'hour,short,views':
return 'CAMSBggBEAEYAQ%3D%3D';
case 'day,short,views':
return 'CAMSBggCEAEYAQ%3D%3D';
case 'week,short,views':
return 'CAMSBggDEAEYAQ%3D%3D';
case 'month,short,views':
return 'CAMSBggEEAEYAQ%3D%3D';
case 'year,short,views':
return 'CAMSBggFEAEYAQ%3D%3D';
case 'any,long,views':
return 'CAMSBBABGAI%3D';
case 'hour,long,views':
return 'CAMSBggBEAEYAg%3D%3D';
case 'day,long,views':
return 'CAMSBggCEAEYAg%3D%3D';
case 'week,long,views':
return 'CAMSBggDEAEYAg%3D%3D';
case 'month,long,views':
return 'CAMSBggEEAEYAg%3D%3D';
case 'year,long,views':
return 'CAMSBggFEAEYAg%3D%3D';
case 'any,any,rating':
return 'CAESAhAB';
case 'hour,any,rating':
return 'CAESBAgBEAE%3D';
case 'day,any,rating':
return 'CAESBAgCEAE%3D';
case 'week,any,rating':
return 'CAESBAgDEAE%3D';
case 'month,any,rating':
return 'CAESBAgEEAE%3D';
case 'year,any,rating':
return 'CAESBAgFEAE%3D';
case 'any,short,rating':
return 'CAESBBABGAE%3D';
case 'hour,short,rating':
return 'CAESBggBEAEYAQ%3D%3D';
case 'day,short,rating':
return 'CAESBggCEAEYAQ%3D%3D';
case 'week,short,rating':
return 'CAESBggDEAEYAQ%3D%3D';
case 'month,short,rating':
return 'CAESBggEEAEYAQ%3D%3D';
case 'year,short,rating':
return 'CAESBggFEAEYAQ%3D%3D';
case 'any,long,rating':
return 'CAESBBABGAI%3D';
case 'hour,long,rating':
return 'CAESBggBEAEYAg%3D%3D';
case 'day,long,rating':
return 'CAESBggCEAEYAg%3D%3D';
case 'week,long,rating':
return 'CAESBggDEAEYAg%3D%3D';
case 'month,long,rating':
return 'CAESBggEEAEYAg%3D%3D';
case 'year,long,rating':
return 'CAESBggFEAEYAg%3D%3D';
default:
}
};
module.exports = { urls, oauth, oauth_reqopts, default_headers, innertube_request_opts, video_details_reqbody, stream_headers, formatVideoData, base64_alphabet, filters };
};

View File

@@ -5,163 +5,176 @@ 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 {
class Innertube {
constructor(cookie) {
super();
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 (!query) throw new Error('No query was provided');
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 = {};
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,
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 '
}
};
}).filter((video_block) => video_block !== undefined);
return search_response;
return refined_data;
}
/**
* Gets details for a video.
*
* @param {string} id The id of the video.
*/
async getDetails(id) {
if (!id) return { error: 'Missing video id' };
if (!id) throw new Error('You must provide a video id');
const data = await this.requestVideoInfo(id, false);
const video_data = Constants.formatVideoData(data, this, false);
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 (video_data.metadata.is_live_content) {
if (refined_data.metadata.is_live_content) {
const data_continuation = await Actions.getContinuation(this, { video_id: id });
if (!data_continuation.data.contents.twoColumnWatchNextResults.conversationBar) return;
video_data.getLivechat = () => new Livechat(this, data_continuation.data.contents.twoColumnWatchNextResults.conversationBar.liveChatRenderer.continuations[0].reloadContinuationData.continuation, video_data.metadata.channel_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 {
video_data.getLivechat = () => {};
refined_data.getLivechat = () => { };
}
video_data.like = () => Actions.engage(this, 'like/like', { video_id: id });
video_data.dislike = () => Actions.engage(this, 'like/dislike', { video_id: id });
video_data.removeLike = () => Actions.engage(this, 'like/removelike', { video_id: id });
video_data.subscribe = () => Actions.engage(this, 'subscription/subscribe', { video_id: id, channel_id: video_data.metadata.channel_id });
video_data.unsubscribe = () => Actions.engage(this, 'subscription/unsubscribe', { video_id: id, channel_id: video_data.metadata.channel_id });
video_data.comment = text => Actions.engage(this, 'comment/create_comment', { video_id: id, text });
video_data.getComments = () => this.getComments(id);
video_data.setNotificationPref = pref => Actions.notifications(this, 'modify_channel_preference', { channel_id: video_data.metadata.channel_id, pref: pref || 'NONE' });
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 video_data;
return refined_data;
}
/**
* Retrieves the lyrics for a given song
*
* @param {string} id
* @returns {string} Song lyrics
*/
async getLyrics(id) {
const data_continuation = await Actions.getContinuation(this, { video_id: id, ytmusic: true });
const lyrics_tab = data_continuation.data.contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer
.watchNextTabbedResultsRenderer.tabs.find((obj) => obj.tabRenderer.title == 'Lyrics');
const response = await Actions.browse(this, 'lyrics', { ytmusic: true, browse_id: lyrics_tab.tabRenderer.endpoint.browseEndpoint.browseId });
if (!response.data.contents.sectionListRenderer) throw new Error(response.data.contents.messageRenderer.text.runs[0].text);
const lyrics = response.data.contents.sectionListRenderer.contents[0].musicDescriptionShelfRenderer.description.runs[0].text;
return lyrics
}
/**
* 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;
@@ -172,7 +185,7 @@ class Innertube extends EventEmitter {
}
const response = await Actions.getContinuation(this, { continuation_token: comment_section_token || token });
if (!response.success) throw new Error('Could not fetch comment section');
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');
@@ -200,7 +213,7 @@ class Innertube extends EventEmitter {
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.simpleText,
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,
}
@@ -211,9 +224,43 @@ class Innertube extends EventEmitter {
return comments_section;
}
/**
* 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');
const contents = response.data.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.richGridRenderer.contents;
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) || [],
owner_badges: content.videoRenderer.ownerBadges && content.videoRenderer.ownerBadges.map((badge) => badge.metadataBadgeRenderer.tooltip) || []
}
}
}).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 fetch 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 = {};
@@ -229,12 +276,12 @@ class Innertube extends EventEmitter {
section_items.forEach((item) => {
const content = {
title: item.gridVideoRenderer.title.runs.map((run) => run.text).join(' '),
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 || [],
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'
@@ -248,6 +295,10 @@ class Innertube extends EventEmitter {
return subscriptions_feed;
}
/**
* Returns your notifications.
* @returns {Promise<object>} notifications.
*/
async getNotifications() {
const response = await Actions.notifications(this, 'get_notification_menu');
if (!response.success) throw new Error('Could not fetch notifications');
@@ -270,23 +321,22 @@ class Innertube extends EventEmitter {
}).filter((notification_block) => notification_block);
}
/**
* 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 fetch unseen notifications count');
if (!response.success) throw new Error('Could not get unseen notifications count');
return response.data.unseenCount;
}
async requestVideoInfo(id, desktop) {
let response;
if (!desktop) {
response = await Axios.get(`${Constants.urls.YT_WATCH_PAGE}?v=${id}&t=8s&pbj=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;
}
/**
* 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');
@@ -298,7 +348,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 });
@@ -328,8 +378,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;
@@ -352,15 +400,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];
@@ -368,16 +411,17 @@ 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') {
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()
headers: Constants.STREAM_HEADERS
}).catch((error) => error);
if (response instanceof Error) {
@@ -388,6 +432,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);
@@ -396,31 +441,29 @@ 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, { 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 end = false;
let must_end = false;
stream.emit('start');
const downloadChunk = async () => {
if (chunk_end >= selected_format.contentLength) end = true;
(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()
headers: Constants.STREAM_HEADERS
}).catch((error) => error);
if (response instanceof Error) {
@@ -444,14 +487,14 @@ class Innertube extends EventEmitter {
});
response.data.on('end', () => {
if (!end) {
if (!must_end && !options.range) {
chunk_start = chunk_end + 1;
chunk_end += chunk_size;
downloadChunk();
}
});
response.data.pipe(stream, { end });
response.data.pipe(stream, { end: must_end });
};
downloadChunk();
}

View File

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

View File

@@ -6,124 +6,132 @@ const Constants = require('./Constants');
class NToken {
constructor(raw_code) {
this.raw_code = raw_code;
this.null_placeholder_regex = /c\[(.*?)\]=c/g;
this.transformation_args_regex = /c\[(.*?)\]\((.+?)\)/g;
this.placeholders_regex = /c\[(.*?)\]=c/g;
this.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('');
let transformations = this.getTransformationData(this.raw_code);
// Identifies the necessary transformation functions and emulates them accordingly.
transformations = transformations.map((el) => {
if (el != null && typeof el != 'number') {
const is_reverse_base64 = el.includes('case 65:');
if (el.includes('function(d){for(var')) {
el = (arr) => this.pushSplice(arr);
} else if (el.includes('d.push(e)')) {
el = (arr, item) => this.push(arr, item);
} else if (el.includes('d.reverse()')) {
el = (arr) => this.reverse(arr);
} else if (el.includes('d.length;d.splice(e,1)')) {
el = (arr, index) => this.spliceOnce(arr, index);
} else if (el.includes('d[0])[0])')) {
el = (arr, index) => this.spliceTwice(arr, index);
} else if (el.includes('reverse().forEach')) {
el = (arr, index) => this.spliceReverseUnshift(arr, index);
} else if (el.includes('f=d[0];d[0]')) {
el = (arr, index) => this.swapFirstItem(arr, index);
} else if (el.includes('unshift(d.pop())')) {
el = (arr, index) => this.unshiftPop(arr, index);
} else if (el.includes('switch')) {
el = (arr, e) => this.translateAB(arr, e, is_reverse_base64);
} else if (el === 'b') {
el = n_token;
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;
});
return el;
});
// Fills the null placeholders with a copy of the transformations array.
let null_placeholder_positions = [...this.raw_code.matchAll(this.null_placeholder_regex)].map((item) => parseInt(item[1]));
null_placeholder_positions.forEach((pos) => transformations[pos] = transformations);
// Fills all placeholders with the transformations array
const placeholder_indexes = [...this.raw_code.matchAll(this.placeholders_regex)].map((item) => parseInt(item[1]));
placeholder_indexes.forEach((i) => transformations[i] = transformations);
// Parses and emulates calls to functions of the transformations array.
let transformation_args = [...Utils.getStringBetweenStrings(this.raw_code.replace(/\n/g, ''), 'try{', '}catch').matchAll(this.transformation_args_regex)].map((params) => ({ index: params[1], params: params[2] }));
transformation_args.forEach((data) => {
const index = data.index;
const param_index = data.params.split(',').map((param) => param.match(/c\[(.*?)\]/)[1]);
transformations[index](transformations[param_index[0]], transformations[param_index[1]]);
});
// Parses and emulates calls to the functions of the transformations array
const function_calls = [...Utils.getStringBetweenStrings(this.raw_code.replace(/\n/g, ''), 'try{', '}catch')
.matchAll(this.calls_regex)].map((params) => ({ index: params[1], params: params[2] }));
function_calls.forEach((data) => {
const param_index = data.params.split(',').map((param) => param.match(/c\[(.*?)\]/)[1]);
const base64_dia = (param_index[2] && transformations[param_index[2]]());
transformations[data.index](transformations[param_index[0]], transformations[param_index[1]], base64_dia);
});
} catch (err) {
console.error(`Could not transform n-token (${n}), download may be throttled:`, err.message);
return n;
}
return n_token.join('');
}
getTransformationData() {
let transformation_data = '[' + Utils.getStringBetweenStrings(this.raw_code, 'c=[', '];c') + ']';
transformation_data = transformation_data
.replace(/function\(d,e\)/g, '"function(d,e)')
.replace(/function\(d\)/g, '"function(d)')
.replace(/,b,/g, ',"b",')
.replace(/,b/g, ',"b"')
.replace(/b,/g, '"b",')
.replace(/b]/g, '"b"]')
.replace(/},/g, '}",')
.replace(/""/g, '')
.replace(/length]\)}"/g, 'length])}');
return JSON.parse(transformation_data);
#getFunc(el) {
return el.match(Constants.FUNCS_REGEX);
}
translateAB(arr, e, is_reverse_base64) {
let characters = is_reverse_base64 && Constants.base64_alphabet.reverse || Constants.base64_alphabet.normal;
arr.forEach(function(char, index, loc) {
/**
* Takes the n-transform data, refines it, and then returns a readable json array.
* @returns {object}
*/
#getTransformationData() {
const data = `[${Utils.getStringBetweenStrings(this.raw_code.replace(/\n/g, ''), 'c=[', '];c')}]`;
return JSON.parse(Utils.refineNTokenData(data));
}
/**
* Gets a base64 alphabet and uses it as a lookup table to modify n.
*/
#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]);
}, e.split(''));
}, token.split(''));
}
unshiftPop(arr, index) {
index = (index % arr.length + arr.length) % arr.length;
for (; index--;) {
arr.unshift(arr.pop());
}
#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(''));
}
swapFirstItem(arr, index) {
let oldValue = arr[0];
/**
* 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] = oldValue;
arr[index] = old_elem;
}
spliceReverseUnshift(arr, index) {
/**
* Rotates elements of the array.
*/
#rotate(arr, index) {
index = (index % arr.length + arr.length) % arr.length;
arr.splice(-index).reverse().forEach(function(f) {
arr.unshift(f);
});
arr.splice(-index).reverse().forEach((el) => arr.unshift(el));
}
spliceOnce(arr, index) {
/**
* Deletes one element at the given index.
*/
#splice(arr, index) {
index = (index % arr.length + arr.length) % arr.length;
arr.splice(index, 1);
}
spliceTwice(arr, index) {
index = (index % arr.length + arr.length) % arr.length;
arr.splice(0, 1, arr.splice(index, 1, arr[0])[0]);
}
pushSplice(arr) {
for (let index = arr.length; index;)
arr.push(arr.splice(--index, 1)[0]);
}
push(arr, item) {
arr.push(item);
}
reverse(arr) {
#reverse(arr) {
arr.reverse();
}
#push(arr, item) {
arr.push(item);
}
}
module.exports = NToken;

View File

@@ -1,91 +1,37 @@
'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`;
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`;
// Used to check whether an access token is valid or not.
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;
// These are always the same, so we shouldn't have any problems for now.
this.model_name = Constants.oauth.model_name;
this.grant_type = Constants.oauth.grant_type;
this.scope = Constants.oauth.scope;
// Script that contains important information such as client id and client secret.
this.auth_script_regex = /<script id=\"base-js\" src=\"(.*?)\" nonce=".*?"><\/script>/;
this.identity_regex = /.+?={};var .+?={clientId:\"(?<id>.+?)\",.+?:\"(?<secret>.+?)\"},/;
// 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();
if (auth_info.access_token) return;
this.#requestAuthCode();
}
waitForAuth(device_code) {
const data = {
client_id: this.client_id,
client_secret: this.client_secret,
code: device_code,
grant_type: this.grant_type
};
/**
* Asks the OAuth server for an auth code.
*/
async #requestAuthCode() {
const identity = await this.#getClientIdentity();
setTimeout(async () => {
const response = await Axios.post(this.oauth_token_url, JSON.stringify(data), Constants.oauth_reqopts).catch((error) => error);
if (response instanceof Error)
return this.emit('auth', {
error: 'Could not get auth token.',
status: 'FAILED'
});
if (response.data.error) {
switch (response.data.error) {
case 'slow_down':
case 'authorization_pending':
this.waitForAuth(device_code);
break;
case 'access_denied':
this.emit('auth', {
error: 'The access was denied.',
status: 'ACCESS_DENIED'
});
break;
case 'expired_token':
this.emit('auth', {
error: 'The device code has expired, requesting a new one.',
status: 'DEVICE_CODE_EXPIRED'
});
this.requestAuthCode();
break;
default:
}
} else {
this.emit('auth', {
access_token: response.data.access_token,
refresh_token: response.data.refresh_token,
token_type: response.data.token_type,
expires: response.data.expires_in,
scope: response.data.scope,
status: 'SUCCESS'
});
}
}, 1000 * this.refresh_interval);
}
async requestAuthCode() {
const identity = await this.getClientIdentity();
this.client_id = identity.id;
this.client_secret = identity.secret;
@@ -96,7 +42,8 @@ class OAuth extends EventEmitter {
model_name: this.model_name
};
const response = await Axios.post(this.oauth_code_url, JSON.stringify(data), Constants.oauth_reqopts).catch((error) => error);
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.',
@@ -112,59 +59,139 @@ class OAuth extends EventEmitter {
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);
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}`);
/**
* 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
};
// 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}`);
setTimeout(async () => {
const response = await Axios.post(this.oauth_token_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
if (response instanceof Error)
return this.emit('auth', {
error: 'Could not get authentication token.',
status: 'FAILED'
});
const identity_function = Utils.getStringBetweenStrings(response.data, '=function(){var a=window.environment', '(function()');
const client_identity = identity_function.match(this.identity_regex).groups;
return client_identity;
if (response.data.error) {
switch (response.data.error) {
case 'slow_down':
case 'authorization_pending':
this.#waitForAuth(device_code);
break;
case 'access_denied':
this.emit('auth', {
error: 'Access was denied.',
status: 'ACCESS_DENIED'
});
break;
case 'expired_token':
this.emit('auth', {
error: 'The device code has expired, requesting a new one.',
status: 'DEVICE_CODE_EXPIRED'
});
this.#requestAuthCode();
break;
default:
}
} else {
const expiration_date = new Date(new Date().getTime() + response.data.expires_in * 1000);
this.emit('auth', {
credentials: {
access_token: response.data.access_token,
refresh_token: response.data.refresh_token,
expires: expiration_date,
},
token_type: response.data.token_type,
status: 'SUCCESS'
});
}
}, 1000 * this.refresh_interval);
}
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,
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}`;
/**
* 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}`);
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';
// 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;
}
}

198
lib/Parser.js Normal file
View File

@@ -0,0 +1,198 @@
'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;
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: () => this.session.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: () => this.session.getLyrics(list_item.playlistItemData.videoId)
};
});
const albums_ms = contents.find((content) => content.musicShelfRenderer.title.runs[0].text == 'Albums');
const albums = albums_ms.musicShelfRenderer.contents.map((item) => {
const list_item = item.musicResponsiveListItemRenderer;
return {
title: list_item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
author: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[2].text,
year: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs.find((run) => /^[12][0-9]{3}$/.test(run.text)).text,
thumbnail: list_item.thumbnail.musicThumbnailRenderer.thumbnail,
};
});
return { songs, videos, albums };
}
#parseVideoInfo() {
const desktop_v = this.args.desktop_v;
const playability_status = desktop_v && this.data.playabilityStatus ||
this.data[2].playerResponse.playabilityStatus;
if (playability_status.status == 'ERROR')
throw new Error(`Could not retrieve details for this video: ${playability_status.status} - ${playability_status.reason}`);
const details = desktop_v && this.data.videoDetails ||
this.data[2].playerResponse.videoDetails;
const microformat = desktop_v && this.data.microformat.playerMicroformatRenderer ||
this.data[2].playerResponse.microformat.playerMicroformatRenderer;
const streaming_data = desktop_v && this.data.streamingData ||
this.data[2].playerResponse.streamingData;
const response = {
id: '',
title: '',
description: '',
thumbnail: [],
metadata: {}
};
const mf_raw_data = Object.entries(microformat);
const dt_raw_data = Object.entries(details);
mf_raw_data.forEach((entry) => {
const key = Utils.camelToSnake(entry[0]);
if (Constants.METADATA_KEYS.includes(key)) {
key == 'view_count' && (response.metadata[key] = parseInt(entry[1])) ||
key == 'owner_profile_url' && (response.metadata.channel_url = entry[1]) ||
key == 'owner_channel_name' && (response.metadata.channel_name = entry[1]) ||
(response.metadata[key] = entry[1]);
} else {
response[key] = entry[1];
}
});
dt_raw_data.forEach((entry) => {
const key = Utils.camelToSnake(entry[0]);
if (Constants.BLACKLISTED_KEYS.includes(key)) return;
if (Constants.METADATA_KEYS.includes(key)) {
key == 'view_count' && (response.metadata[key] = parseInt(entry[1])) ||
(response.metadata[key] = entry[1]);
} else {
key == 'short_description' && (response.description = entry[1]) ||
key == 'thumbnail' && (response.thumbnail = entry[1].thumbnails.slice(-1)[0]) ||
key == 'video_id' && (response.id = entry[1]) ||
(response[key] = entry[1]);
}
});
if (!desktop_v) {
const dislike_available = this.data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1]
.slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[1].slimMetadataToggleButtonRenderer
.button.toggleButtonRenderer.defaultText.accessibility && true || false;
response.metadata.likes = parseInt(this.data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1]
.slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[0].slimMetadataToggleButtonRenderer
.button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, ''));
response.metadata.dislikes = dislike_available && parseInt(this.data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1]
.slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[1].slimMetadataToggleButtonRenderer
.button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, '')) || 0;
}
response.metadata.available_qualities = [...new Set(streaming_data.adaptiveFormats.filter(v => v.qualityLabel)
.map(v => v.qualityLabel).sort((a, b) => + a.replace(/\D/gi, '') - + b.replace(/\D/gi, '')))];
return response;
}
}
module.exports = Parser;

View File

@@ -1,42 +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 manipulation_algorithm_code = Utils.getStringBetweenStrings(data, 'this.audioTracks};var', '};');
const manipulation_sequence_code = Utils.getStringBetweenStrings(data, 'function(a){a=a.split("")', 'return a.join("")}');
this.sig_decipher_sc = manipulation_algorithm_code + manipulation_sequence_code;
#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) {
this.ntoken_sc = 'var b=a.split("")' + Utils.getStringBetweenStrings(data, 'b=a.split("")', '}return b.join("")}') + '} return b.join("");';
#getNEncoder(data) {
return `var b=a.split("")${Utils.getStringBetweenStrings(data, 'b=a.split("")', '}return b.join("")}')}} return b.join("");`;
}
}

View File

@@ -12,18 +12,21 @@ class SigDecipher {
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) {
@@ -49,7 +52,6 @@ class SigDecipher {
}
const url_components = new URL(args.url);
args.sp !== undefined ? url_components.searchParams.set(args.sp, signature.join('')) : url_components.searchParams.set('signature', signature.join(''));
url_components.searchParams.set('cver', this.cver);
url_components.searchParams.set('ratebypass', 'yes');
@@ -57,7 +59,7 @@ class SigDecipher {
return url_components.toString();
}
getFunctions() {
#getFunctions() {
let func;
let func_name = [];

View File

@@ -5,6 +5,11 @@ 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':
@@ -15,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);
@@ -27,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);
@@ -37,6 +55,36 @@ function escapeStringRegexp(string) {
return string.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&").replace(/-/g, "\\x2d");
}
/**
* Converts time (h:m:s) to seconds.
*
* @param {string} time
* @returns {string} seconds
*/
function timeToSeconds(time) {
let params = time.split(':');
return parseInt(({
3: +params[0] * 3600 + +params[1] * 60 + +params[2],
2: +params[0] * 60 + +params[1],
1: +params[0]
})[params.length]);
}
/**
* Converts strings in camelCase to snake_case.
*
* @param {string} string The string in camelCase.
*/
function camelToSnake(string) {
return string[0].toLowerCase() + string.slice(1, string.length).replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
}
/**
* Encodes notification preferences protobuf.
*
* @param {string} channel_id
* @param {string} index
*/
function encodeNotificationPref(channel_id, index) {
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
@@ -52,7 +100,14 @@ function encodeNotificationPref(channel_id, index) {
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
function generateMessageParams(channel_id, video_id) {
/**
* 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({
@@ -69,7 +124,13 @@ function generateMessageParams(channel_id, video_id) {
return Buffer.from(encodeURIComponent(Buffer.from(buf).toString('base64'))).toString('base64');
}
function generateCommentParams(video_id) {
/**
* 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({
@@ -83,4 +144,46 @@ function generateCommentParams(video_id) {
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
module.exports = { getRandomUserAgent, generateSidAuth, getStringBetweenStrings, generateMessageParams, generateCommentParams, encodeNotificationPref };
/**
* 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'));
}
/**
* Turns the ntoken transform data into a valid json array
*
* @param {string} data
*/
function refineNTokenData(data) {
return data
.replace(/function\(d,e\)/g, '"function(d,e)').replace(/function\(d\)/g, '"function(d)')
.replace(/function\(\)/g, '"function()').replace(/function\(d,e,f\)/g, '"function(d,e,f)')
.replace(/\[function\(d,e,f\)/g, '["function(d,e,f)').replace(/,b,/g, ',"b",')
.replace(/,b/g, ',"b"').replace(/b,/g, '"b",').replace(/b]/g, '"b"]')
.replace(/\[b/g, '["b"').replace(/}]/g, '"]').replace(/},/g, '}",')
.replace(/""/g, '').replace(/length]\)}"/g, 'length])}');
}
module.exports = { getRandomUserAgent, generateSidAuth, getStringBetweenStrings, camelToSnake, timeToSeconds, encodeMessageParams, encodeCommentParams, encodeNotificationPref, encodeFilter, refineNTokenData };

View File

@@ -31,4 +31,14 @@ message CreateCommentParams {
}
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;
}

67
package-lock.json generated
View File

@@ -1,17 +1,16 @@
{
"name": "youtubei.js",
"version": "1.2.2",
"version": "1.3.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "youtubei.js",
"version": "1.2.2",
"version": "1.2.9",
"license": "MIT",
"dependencies": {
"axios": "^0.21.4",
"protons": "^2.0.3",
"time-to-seconds": "^1.1.5",
"user-agents": "^1.0.778",
"uuid": "^8.3.2"
}
@@ -54,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",
@@ -78,9 +77,9 @@
"integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8="
},
"node_modules/multiformats": {
"version": "9.4.9",
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.4.9.tgz",
"integrity": "sha512-zA84TTJcRfRMpjvYqy63piBbSEdqlIGqNNSpP6kspqtougqjo60PRhIFo+oAxrjkof14WMCImvr7acK6rPpXLw=="
"version": "9.6.3",
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.6.3.tgz",
"integrity": "sha512-yfXKI66fL0nFzt0nJl26i4wV1qAqbAEIBvfFbkbsne9GrLz6IHvHUoRyxUtlJcdP181ssOgjama6E/VSk4pbrA=="
},
"node_modules/protocol-buffers-schema": {
"version": "3.6.0",
@@ -106,15 +105,6 @@
"varint": "~5.0.0"
}
},
"node_modules/time-to-seconds": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/time-to-seconds/-/time-to-seconds-1.1.5.tgz",
"integrity": "sha512-mpzJDHGF4VdhiahyusCUSy+BWJdN3q8Cluzfy0n7GMU9IIj+HJDX9bbbr7wVSUiqmRn1vqhhfECgdfj+SByu2A==",
"engines": {
"node": ">=15.0.1",
"vscode": "^1.22.0"
}
},
"node_modules/uint8arrays": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz",
@@ -124,9 +114,9 @@
}
},
"node_modules/underscore": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.1.tgz",
"integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g=="
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.2.tgz",
"integrity": "sha512-ekY1NhRzq0B08g4bGuX4wd2jZx5GnKz6mKSqFL4nqBlfyMGiG10gDFhDTMEfYmDL6Jy0FUIZp7wiRB+0BP7J2g=="
},
"node_modules/underscore-keypath": {
"version": "0.0.22",
@@ -137,9 +127,9 @@
}
},
"node_modules/user-agents": {
"version": "1.0.814",
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.814.tgz",
"integrity": "sha512-OU3lCkkaUCghnZiZuN2edSJ+//ituJihs3P1FCjCDwe1tMBS8eni68CQ0TBbPoVPDtoAqxM3Z7qbP7EWGlhBxA==",
"version": "1.0.918",
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.918.tgz",
"integrity": "sha512-rHqVET1f+DsrIY8ejUSSgjRKb8qJN8//TJ7K2jIgSDR45OJiZWYVMTtg4qkMAKzkaXhcc6BeQQE2M70cXvHqWw==",
"dependencies": {
"dot-json": "^1.2.2",
"lodash.clonedeep": "^4.5.0"
@@ -189,9 +179,9 @@
}
},
"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",
@@ -199,9 +189,9 @@
"integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8="
},
"multiformats": {
"version": "9.4.9",
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.4.9.tgz",
"integrity": "sha512-zA84TTJcRfRMpjvYqy63piBbSEdqlIGqNNSpP6kspqtougqjo60PRhIFo+oAxrjkof14WMCImvr7acK6rPpXLw=="
"version": "9.6.3",
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.6.3.tgz",
"integrity": "sha512-yfXKI66fL0nFzt0nJl26i4wV1qAqbAEIBvfFbkbsne9GrLz6IHvHUoRyxUtlJcdP181ssOgjama6E/VSk4pbrA=="
},
"protocol-buffers-schema": {
"version": "3.6.0",
@@ -227,11 +217,6 @@
"varint": "~5.0.0"
}
},
"time-to-seconds": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/time-to-seconds/-/time-to-seconds-1.1.5.tgz",
"integrity": "sha512-mpzJDHGF4VdhiahyusCUSy+BWJdN3q8Cluzfy0n7GMU9IIj+HJDX9bbbr7wVSUiqmRn1vqhhfECgdfj+SByu2A=="
},
"uint8arrays": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz",
@@ -241,9 +226,9 @@
}
},
"underscore": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.1.tgz",
"integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g=="
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.2.tgz",
"integrity": "sha512-ekY1NhRzq0B08g4bGuX4wd2jZx5GnKz6mKSqFL4nqBlfyMGiG10gDFhDTMEfYmDL6Jy0FUIZp7wiRB+0BP7J2g=="
},
"underscore-keypath": {
"version": "0.0.22",
@@ -254,9 +239,9 @@
}
},
"user-agents": {
"version": "1.0.814",
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.814.tgz",
"integrity": "sha512-OU3lCkkaUCghnZiZuN2edSJ+//ituJihs3P1FCjCDwe1tMBS8eni68CQ0TBbPoVPDtoAqxM3Z7qbP7EWGlhBxA==",
"version": "1.0.918",
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.918.tgz",
"integrity": "sha512-rHqVET1f+DsrIY8ejUSSgjRKb8qJN8//TJ7K2jIgSDR45OJiZWYVMTtg4qkMAKzkaXhcc6BeQQE2M70cXvHqWw==",
"requires": {
"dot-json": "^1.2.2",
"lodash.clonedeep": "^4.5.0"

View File

@@ -1,10 +1,10 @@
{
"name": "youtubei.js",
"version": "1.2.2",
"version": "1.3.0",
"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",
@@ -15,7 +15,6 @@
"dependencies": {
"axios": "^0.21.4",
"protons": "^2.0.3",
"time-to-seconds": "^1.1.5",
"user-agents": "^1.0.778",
"uuid": "^8.3.2"
},
@@ -26,6 +25,7 @@
"keywords": [
"youtube",
"youtube-dl",
"youtubedl",
"innertube",
"innertubeapi",
"livechat",
@@ -34,7 +34,6 @@
"like",
"dislike",
"comment",
"automation",
"downloader",
"comments-section",
"youtube-downloader"

24
test/constants.js Normal file
View 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
View 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), `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();