From 1ab302319d56fdecec6d96bb989cb868829e88a2 Mon Sep 17 00:00:00 2001
From: "luan.lrt4@gmail.com"
Date: Wed, 13 Apr 2022 01:47:57 -0300
Subject: [PATCH] refactor!: rewrite parser and refactor project structure,
closes #19
---
.github/ISSUE_TEMPLATE/ISSUE.md | 3 +-
.github/pull_request_template.md | 27 +
LICENSE | 2 +
README.md | 830 ++++++++++--------
examples/index.js | 182 ++--
lib/Innertube.js | 295 ++++---
lib/Parser.js | 332 -------
lib/{ => core}/Actions.js | 66 +-
lib/{ => core}/Livechat.js | 8 +-
lib/{ => core}/OAuth.js | 8 +-
lib/{ => core}/Player.js | 8 +-
lib/{ => deciphers}/NToken.js | 27 +-
lib/{ => deciphers}/Sig.js | 4 +-
lib/parser/index.js | 255 ++++++
lib/parser/youtube/index.js | 6 +
lib/parser/youtube/others/PlaylistItem.js | 26 +
lib/parser/youtube/search/VideoResultItem.js | 43 +
lib/parser/ytmusic/index.js | 11 +
lib/parser/ytmusic/others/PlaylistItem.js | 28 +
lib/parser/ytmusic/search/AlbumResultItem.js | 21 +
lib/parser/ytmusic/search/ArtistResultItem.js | 19 +
.../ytmusic/search/PlaylistResultItem.js | 23 +
lib/parser/ytmusic/search/SongResultItem.js | 22 +
lib/parser/ytmusic/search/TopResultItem.js | 32 +
lib/parser/ytmusic/search/VideoResultItem.js | 22 +
lib/{ => utils}/Constants.js | 10 +-
lib/{ => utils}/Utils.js | 10 +-
package-lock.json | 19 +-
package.json | 25 +-
test/index.js | 136 +--
30 files changed, 1407 insertions(+), 1093 deletions(-)
create mode 100644 .github/pull_request_template.md
delete mode 100644 lib/Parser.js
rename lib/{ => core}/Actions.js (93%)
rename lib/{ => core}/Livechat.js (98%)
rename lib/{ => core}/OAuth.js (98%)
rename lib/{ => core}/Player.js (90%)
rename lib/{ => deciphers}/NToken.js (85%)
rename lib/{ => deciphers}/Sig.js (96%)
create mode 100644 lib/parser/index.js
create mode 100644 lib/parser/youtube/index.js
create mode 100644 lib/parser/youtube/others/PlaylistItem.js
create mode 100644 lib/parser/youtube/search/VideoResultItem.js
create mode 100644 lib/parser/ytmusic/index.js
create mode 100644 lib/parser/ytmusic/others/PlaylistItem.js
create mode 100644 lib/parser/ytmusic/search/AlbumResultItem.js
create mode 100644 lib/parser/ytmusic/search/ArtistResultItem.js
create mode 100644 lib/parser/ytmusic/search/PlaylistResultItem.js
create mode 100644 lib/parser/ytmusic/search/SongResultItem.js
create mode 100644 lib/parser/ytmusic/search/TopResultItem.js
create mode 100644 lib/parser/ytmusic/search/VideoResultItem.js
rename lib/{ => utils}/Constants.js (98%)
rename lib/{ => utils}/Utils.js (94%)
diff --git a/.github/ISSUE_TEMPLATE/ISSUE.md b/.github/ISSUE_TEMPLATE/ISSUE.md
index 149cd48b..c1d5b7ac 100644
--- a/.github/ISSUE_TEMPLATE/ISSUE.md
+++ b/.github/ISSUE_TEMPLATE/ISSUE.md
@@ -35,5 +35,4 @@ Please include any relevant log snippets or files here.
- [ ] I am running the latest version
- [ ] I checked the documentation and found no answer
- [ ] I checked to make sure that this issue has not already been filed
-- [ ] I'm reporting the issue to the correct repository (for multi-repository projects)
-- [ ] I have provided sufficient information
\ No newline at end of file
+- [ ] I have provided sufficient information
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 00000000..8452b6ec
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,27 @@
+# Pull Request Template
+
+## Description
+
+Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.
+
+Fixes # (issue)
+
+## Type of change
+
+Please delete options that are not relevant.
+
+- [ ] Bug fix (non-breaking change which fixes an issue)
+- [ ] New feature (non-breaking change which adds functionality)
+- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
+- [ ] This change requires a documentation update
+
+## Checklist:
+
+- [ ] My code follows the style guidelines of this project
+- [ ] I have performed a self-review of my own code
+- [ ] I have commented my code, particularly in hard-to-understand areas
+- [ ] I have made corresponding changes to the documentation
+- [ ] My changes generate no new warnings
+- [ ] I have added tests that prove my fix is effective or that my feature works
+- [ ] New and existing unit tests pass locally with my changes
+- [ ] I have checked my code and corrected any misspellings
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
index d46bb1e0..03f4f40d 100644
--- a/LICENSE
+++ b/LICENSE
@@ -19,3 +19,5 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+S IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index cb2697db..de47a5cb 100644
--- a/README.md
+++ b/README.md
@@ -1,66 +1,114 @@
YouTube.js
-An object-oriented wrapper around the Innertube API, which is what YouTube itself uses.
- Report Bug
+ A full-featured wrapper around the Innertube API, which is what YouTube itself uses.
+
+
+
+ Report Bug
·
- Request Feature
-
-
-
-
-
-
+ Request Feature
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Table of Contents
+
+ -
+ About The Project
+
+
+ -
+ Getting Started
+
+
+ -
+ Usage
+
+
+ - Contributing
+ - License
+ - Contact
+ - Disclaimer
+
+
+
+
+## About
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!
-#### What can it do?
+### Features
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, playlists, music, albums etc
- Get detailed info about any video or playlist
- Fetch live chat & live stats in real time
-- Get notifications
-- Get watch history
-- Get subscriptions/home feed
- Change notification preferences for a channel
- Subscribe/Unsubscribe/Like/Dislike/Comment etc
- Easily sign in to any Google Account
- Change an account's settings.
+- Get subscriptions/home feed
+- Get notifications
+- Get watch history
- Download videos
Do note that you must be signed-in to perform actions that involve an account, such as commenting, subscribing, sending messages to a live chat, etc.
-#### Do I need an API key to use this?
+### Do I need an API key to use this?
No, YouTube.js does not use any official API so no API keys are required.
-## Installation
+
+## Getting Started
-```bash
-npm install youtubei.js
-```
+### Prerequisites
+- [NodeJS](https://nodejs.org) v14 or greater
+ To verify things are set up
+properly, you can run this:
+ ```bash
+ node --version
+ ```
+
+
+### Installation
+- NPM:
+ ```bash
+ npm install youtubei.js@latest
+ ```
+- Yarn:
+ ```bash
+ yarn add youtubei.js@latest
+ ```
+
+
## Usage
-
-[1. Getting Started](#usage)
-
-[2. Interactions](#interactions)
-
-[3. Live chats](#fetching-live-chats)
-
-[4. Downloading videos](#downloading-videos)
-
-[5. Signing-in](#signing-in)
-
-[6. Disclaimer](#disclaimer)
-
First of all we're gonna start by initializing the Innertube instance.
And to make things faster, you should do this only once and reuse the Innertube object when needed.
@@ -69,18 +117,20 @@ const Innertube = require('youtubei.js');
const youtube = await new Innertube();
```
-Doing a simple search:
+### Doing a simple search
+YouTube:
```js
-// YouTube:
const search = await youtube.search('Looking for life on Mars - Documentary');
+```
-// YTMusic:
+YTMusic:
+```js
const search = await youtube.search('Interstellar Main Theme', { client: 'YTMUSIC' });
```
-YouTube Search Output
+YouTube Output
```js
@@ -91,9 +141,9 @@ const search = await youtube.search('Interstellar Main Theme', { client: 'YTMUSI
videos: [
{
id: string,
+ url: string,
title: string,
description: string,
- url: string,
metadata:{
view_count: string,
short_view_count_text: {
@@ -120,67 +170,80 @@ const search = await youtube.search('Interstellar Main Theme', { client: 'YTMUSI
-YouTube Music Search Output
+YTMusic Output
```js
{
- songs: [
- {
- id: string,
- title: string,
- artist: string,
- album: string,
- duration: string,
- thumbnail: {
- thumbnails: [Array]
- }
- },
- //...
- ],
- videos: [
- {
- id: string,
- title: string,
- author: string,
- views: string,
- duration: string,
- thumbnail: {
- thumbnails: [Array]
- }
- }
- //...
- ],
- albums: [
- {
- title: string,
- author: string,
- year: string,
- thumbnail: {
- thumbnails: [Array]
- }
- },
- //...
- ],
- playlists: [
- {
- title: string,
- description: string,
- total_items: number,
- duration: string,
- year: string,
- items: [
- {
- id: string,
- title: string,
- author: string,
- duration: string,
- thumbnail: [Array]
- }
- ]
- }
- ]
+ query:string,
+ corrected_query:string,
+ results:{
+ top_result:[Array], // Can be anything; video, playlist, artist etc..
+ songs:[
+ {
+ id:string,
+ title:string,
+ artist:string,
+ album:string,
+ duration:string,
+ thumbnails:[
+ Array
+ ]
+ },
+ //...
+ ],
+ videos:[
+ {
+ id:string,
+ title:string,
+ author:string,
+ views:string,
+ duration:string,
+ thumbnails:[Array]
+ },
+ //...
+ ],
+ albums:[
+ {
+ id:string,
+ title:string,
+ author:string,
+ year:string,
+ thumbnails:[Array]
+ },
+ //...
+ ],
+ featured_playlists:[
+ {
+ id:string,
+ title:string,
+ author:string,
+ channel_id:string,
+ total_items:number
+ },
+ //...
+ ],
+ community_playlists:[
+ {
+ id:string,
+ title:string,
+ author:string,
+ channel_id:string,
+ total_items:number
+ },
+ //...
+ ],
+ artists:[
+ {
+ id:string,
+ name:string,
+ subscribers:string,
+ thumbnails:[Array]
+ },
+ //...
+ ]
+ }
}
```
@@ -188,7 +251,7 @@ const search = await youtube.search('Interstellar Main Theme', { client: 'YTMUSI
-Get search suggestions:
+### Get search suggestions:
```js
const suggestions = await youtube.getSearchSuggestions('QUERY', {
client: 'YOUTUBE' // Use YTMUSIC if you want music search suggestions
@@ -211,7 +274,7 @@ const suggestions = await youtube.getSearchSuggestions('QUERY', {
-Get details about a given video:
+### Get video info:
```js
const video = await youtube.getDetails('VIDEO_ID');
@@ -271,27 +334,17 @@ const video = await youtube.getDetails('VIDEO_ID');
-Get comments:
+### Get comments:
```js
const response = await youtube.getComments('VIDEO_ID');
+```
+Alternatively you can use:
-// Or:
+```js
const video = await youtube.getDetails('VIDEO_ID');
const response = await video.getComments();
-
-// Get comment replies:
-const replies = await response.comments[0].getReplies();
-
-// Like, dislike, reply (same logic for replies):
-await response.comments[0].like();
-await response.comments[0].dislike();
-await response.comments[0].reply('Nice comment!');
-
-// Get comments continuation (same logic for replies):
-const continuation = await response.getContinuation();
```
-
Output
@@ -332,7 +385,25 @@ const continuation = await response.getContinuation();
-Get home feed:
+Reply to, like and dislike comments:
+```js
+await response.comments[0].like();
+await response.comments[0].dislike();
+await response.comments[0].reply('Nice comment!');
+```
+
+Get comment replies:
+```js
+const replies = await response.comments[0].getReplies();
+```
+
+Get comments/replies continuation:
+```js
+const continuation = await response.getContinuation();
+const replies_continuation = await replies.getContinuation();
+```
+
+### Get home feed:
```js
const homefeed = await youtube.getHomeFeed();
```
@@ -340,63 +411,22 @@ const homefeed = await youtube.getHomeFeed();
Output
-```js
-[
- {
- id: string,
- title: string,
- description: string,
- channel: string,
- metadata: {
- view_count: string,
- short_view_count_text: { simple_text: string, accessibility_label: string },
- thumbnail: {
- url: string,
- width: number,
- height: number
- },
- moving_thumbnail: {
- url: string,
- width: number,
- height: number
- },
- published: string,
- duration: {
- seconds: number,
- simple_text: string,
- accessibility_label: string
- },
- badges: string,
- owner_badges: [Array]
- }
- }
- // ...
-]
-```
-
-
-
-
-Get subscriptions feed:
-```js
-const mysubsfeed = await youtube.getSubscriptionsFeed();
-```
-
-
-Output
-
-
```js
{
- today: [
- {
- id: string,
- title: string,
- channel: string,
- metadata: {
- view_count: string,
- short_view_count_text: { simple_text: string, accessibility_label: string },
- thumbnail: {
+ videos: [
+ {
+ id: string,
+ title: string,
+ description: string,
+ channel: {
+ id: string,
+ name: string,
+ url: string
+ },
+ metadata: {
+ view_count: string,
+ short_view_count_text: { simple_text: string, accessibility_label: string },
+ thumbnail: {
url: string,
width: number,
height: number
@@ -407,67 +437,29 @@ const mysubsfeed = await youtube.getSubscriptionsFeed();
height: number
},
published: string,
+ duration: {
+ seconds: number,
+ simple_text: string,
+ accessibility_label: string
+ },
badges: string,
owner_badges: [Array]
- }
- }
- //...
- ],
- yesterday: [
- {
- id: string,
- title: string,
- channel: string,
- metadata: {
- view_count: string,
- short_view_count_text: { simple_text: string, accessibility_label: string },
- thumbnail: {
- url: string,
- width: number,
- height: number
- },
- moving_thumbnail: {
- url: string,
- width: number,
- height: number
- },
- published: string,
- badges: string,
- owner_badges: [Array]
- }
- }
- //...
- ],
- this_week: [
- id: string,
- title: string,
- channel: string,
- metadata: {
- view_count: string,
- thumbnail: {
- url: string,
- width: number,
- height: number
- },
- moving_thumbnail: {
- url: string,
- width: number,
- height: number
- },
- published: string,
- badges: string,
- owner_badges: [Array]
- }
- }
- // ...
- ]
-}
+ }
+ },
+ // ...
+ ]
+}
```
-Get watch history:
+Get continuation:
+```js
+const continuation = await homefeed.getContinuation();
+````
+
+### Get watch history:
```js
const history = await youtube.getHistory();
```
@@ -478,45 +470,118 @@ const history = await youtube.getHistory();
```js
-[
- {
- id: string,
- title: string,
- description: string,
- channel: {
- name: string,
- url: string
- },
- metadata: {
- view_count: string,
- short_view_count_text: { simple_text: string, accessibility_label: string },
- thumbnail: {
- url: string,
- width: number,
- height: number
+{
+ items: [
+ {
+ date: string,
+ videos: [
+ {
+ id: string,
+ title: string,
+ channel: {
+ id: string,
+ name: string,
+ url: string
+ },
+ metadata: {
+ view_count: string,
+ short_view_count_text: {
+ simple_text: string,
+ accessibility_label: string
+ },
+ thumbnail: {
+ url: string,
+ width: number,
+ height: number
+ },
+ moving_thumbnail: {
+ url: string,
+ width: number,
+ height: number
+ },
+ published: string,
+ badges: [Array],
+ owner_badges: [Array]
+ }
+ },
+ //...
+ ]
},
- moving_thumbnail: {
- url: string,
- width: number,
- height: number
- },
- published: string,
- duration: {
- seconds: number,
- simple_text: string,
- accessibility_label: string
- },
- badges: string,
- owner_badges: [Array]
- }
- }
-]
+ //...
+ ]
+}
```
-Get notifications:
+Get continuation:
+```js
+const continuation = await history.getContinuation();
+````
+
+### Get subscriptions feed:
+```js
+const mysubsfeed = await youtube.getSubscriptionsFeed();
+```
+
+
+Output
+
+
+```js
+{
+ items: [
+ {
+ date: string,
+ videos: [
+ {
+ id: string,
+ title: string,
+ description: string,
+ channel: {
+ id: string,
+ name: string,
+ url: string
+ },
+ metadata: {
+ view_count: string,
+ short_view_count_text: {
+ simple_text: string,
+ accessibility_label: string
+ },
+ thumbnail: {
+ url: string,
+ width: number,
+ height: number
+ },
+ moving_thumbnail: {
+ url: string,
+ width: number,
+ height: number
+ },
+ published: string,
+ badges: [Array],
+ owner_badges: [Array]
+ }
+ },
+ //...
+ ]
+ },
+ //...
+ ]
+}
+```
+
+
+
+
+Get continuation:
+```js
+const continuation = await mysubsfeed.getContinuation();
+````
+
+### Get notifications:
```js
const notifications = await youtube.getNotifications();
@@ -527,8 +592,9 @@ const notifications = await youtube.getNotifications();
```js
-[
- {
+{
+ items: [
+ {
title: string,
sent_time: string,
channel_name: string,
@@ -545,68 +611,45 @@ const notifications = await youtube.getNotifications();
video_url: string,
read: boolean,
notification_id: string
- },
- //...
-]
-```
-
-
-
-
-Get unseen notifications count:
-
-```js
-const notifications = await youtube.getUnseenNotificationsCount();
-```
-
-Get song lyrics:
-```js
-const search = await youtube.search('Never give you up', { client: 'YTMUSIC' });
-const lyrics = await youtube.getLyrics(search.songs[0].id);
-```
-
-Get playlist:
-```js
-const search = await youtube.search('Interstellar Soundtrack', {
- client: 'YTMUSIC'
-});
-
-// YouTube Music
-const playlist = await youtube.getPlaylist(search.playlists[0].id, {
- client: 'YTMUSIC'
-});
-
-// YouTube (default)
-const playlist = await youtube.getPlaylist(search.playlists[0].id);
-```
-
-
-
-YouTube Music Output
-
-
-```js
-{
- title: string,
- description: string,
- total_items: number,
- duration: string,
- year: string,
- items: [
- {
- id: string,
- title: string,
- author: string,
- duration: string,
- thumbnail: [Array]
},
//...
+ ]
}
```
+Get continuation:
+```js
+const continuation = await notifications.getContinuation();
+````
+
+### Get unseen notifications count:
+
+```js
+const notifications = await youtube.getUnseenNotificationsCount();
+```
+
+### Get song lyrics:
+```js
+const search = await youtube.search('Never give you up', { client: 'YTMUSIC' });
+const lyrics = await youtube.getLyrics(search.results.songs[0].id);
+```
+
+### Get playlist:
+YouTube (default):
+```js
+const playlist = await youtube.getPlaylist('PLAYLIST_ID');
+```
+
+YouTube Music:
+```js
+const playlist = await youtube.getPlaylist('PLAYLIST_ID', {
+ client: 'YTMUSIC'
+});
+```
+
YouTube Output
@@ -628,7 +671,7 @@ const playlist = await youtube.getPlaylist(search.playlists[0].id);
simple_text: string,
accessibility_label: string
},
- thumbnail: [Array]
+ thumbnails: [Array]
},
//...
]
@@ -638,35 +681,63 @@ const playlist = await youtube.getPlaylist(search.playlists[0].id);
+
+YouTube Music Output
+
+
+```js
+{
+ title: string,
+ description: string,
+ total_items: number,
+ duration: string,
+ year: string,
+ items: [
+ {
+ id: string,
+ title: string,
+ author: string,
+ duration: {
+ seconds: number,
+ simple_text: string
+ },
+ thumbnails: [Array]
+ },
+ //...
+}
+```
+
+
+
+
### Interactions:
---
The library makes it easy to interact with YouTube programmatically. However, don't forget that you must be signed in to use the following features!
* Subscribe/Unsubscribe:
-
-```js
-await youtube.interact.subscribe('CHANNEL_ID');
-await youtube.interact.unsubscribe('CHANNEL_ID');
-```
+ ```js
+ await youtube.interact.subscribe('CHANNEL_ID');
+ await youtube.interact.unsubscribe('CHANNEL_ID');
+ ```
* Like/Dislike:
-```js
-await youtube.interact.like('VIDEO_ID');
-await youtube.interact.dislike('VIDEO_ID');
-await youtube.interact.removeLike('VIDEO_ID');
-```
+ ```js
+ await youtube.interact.like('VIDEO_ID');
+ await youtube.interact.dislike('VIDEO_ID');
+ await youtube.interact.removeLike('VIDEO_ID');
+ ```
* Comment:
-```js
-await youtube.interact.comment('VIDEO_ID', 'Haha, nice video!');
-```
+ ```js
+ await youtube.interact.comment('VIDEO_ID', 'Haha, nice video!');
+ ```
* Change notification preferences:
-```js
-// Options: ALL | NONE | PERSONALIZED
-await youtube.interact.changeNotificationPreferences('CHANNEL_ID', 'ALL');
-```
+ ```js
+ // Options: ALL | NONE | PERSONALIZED
+ await youtube.interact.changeNotificationPreferences('CHANNEL_ID', 'ALL');
+ ```
These methods will always return ```{ success: true, status_code: 200 }``` if successful.
@@ -674,73 +745,73 @@ These methods will always return ```{ success: true, status_code: 200 }``` if su
It is also possible to manage an account's settings:
* Get account info:
-```js
-await youtube.account.info();
-```
+ ```js
+ await youtube.account.info();
+ ```
-
-Output
-
+
+ Output
+
-```js
-{
- name: string,
- photo: [
- {
- url: string,
- width: number,
- height: number
- }
- ],
- country: string,
- language: string;
-}
-```
-
-
+ ```js
+ {
+ name: string,
+ photo: [
+ {
+ url: string,
+ width: number,
+ height: number
+ }
+ ],
+ country: string,
+ language: string;
+ }
+ ```
+
+
#### Notification settings:
* Subscription notifications:
-```js
-await youtube.account.settings.notifications.setSubscriptions(true);
-```
+ ```js
+ await youtube.account.settings.notifications.setSubscriptions(true);
+ ```
* Recommended content notifications:
-```js
-await youtube.account.settings.notifications.setRecommendedVideos(true);
-```
+ ```js
+ await youtube.account.settings.notifications.setRecommendedVideos(true);
+ ```
* Channel activity notifications:
-```js
-await youtube.account.settings.notifications.setChannelActivity(true);
-```
+ ```js
+ await youtube.account.settings.notifications.setChannelActivity(true);
+ ```
* Comment replies notifications:
-```js
-await youtube.account.settings.notifications.setCommentReplies(true);
-```
+ ```js
+ await youtube.account.settings.notifications.setCommentReplies(true);
+ ```
* Channel mention notifications:
-```js
-await youtube.account.settings.notifications.setSharedContent(true);
-```
+ ```js
+ await youtube.account.settings.notifications.setSharedContent(true);
+ ```
#### Privacy settings:
* Subscriptions privacy:
-```js
-await youtube.account.settings.privacy.setSubscriptionsPrivate(true);
-```
+ ```js
+ await youtube.account.settings.privacy.setSubscriptionsPrivate(true);
+ ```
* Saved playlists privacy:
-```js
-await youtube.account.settings.privacy.setSavedPlaylistsPrivate(true);
-```
+ ```js
+ await youtube.account.settings.privacy.setSavedPlaylistsPrivate(true);
+ ```
-### Fetching live chats:
+### Live chats:
---
YouTube.js isn't able to download live content yet, but it does allow you to fetch live chats plus you can also send messages!
@@ -843,7 +914,7 @@ const stream = youtube.download(VIDEO_ID, {
```
-Cancelling a download:
+Cancel a download:
```js
stream.cancel();
```
@@ -922,7 +993,7 @@ When signing in to your account, you have two options:
- Use OAuth 2.0; easy, simple & reliable.
- Cookies; usually more complicated to get and unreliable.
-OAuth:
+#### OAuth:
```js
const fs = require('fs');
@@ -955,7 +1026,7 @@ async function start() {
start();
```
-Cookies:
+#### Cookies:
```js
const Innertube = require('youtubei.js');
@@ -968,16 +1039,29 @@ async function start() {
start();
```
+
## 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.
+
+
+## Contact
+
+LuanRT - [@lrt_nooneknows](https://twitter.com/lrt_nooneknows) - luan.lrt4@gmail.com
+
+Project Link: [https://github.com/LuanRT/YouTube.js](https://github.com/LuanRT/YouTube.js)
+
+
## Disclaimer
This project is not affiliated with, endorsed, or sponsored by YouTube or any of their affiliates or subsidiaries.
All trademarks, logos and brand names are the property of their respective owners.
Should you have any questions or concerns please contact me directly via email.
+
## License
-[MIT](https://choosealicense.com/licenses/mit/)
+Distributed under the [MIT](https://choosealicense.com/licenses/mit/) License.
+
+(back to top)
diff --git a/examples/index.js b/examples/index.js
index 543106c6..8779b4fd 100644
--- a/examples/index.js
+++ b/examples/index.js
@@ -1,92 +1,92 @@
-'use strict';
-
-const fs = require('fs');
-const Innertube = require('..');
-const creds_path = './yt_oauth_creds.json';
-const creds = fs.existsSync(creds_path) && JSON.parse(fs.readFileSync(creds_path).toString()) || {};
-
-async function start() {
- const youtube = await new Innertube();
-
- youtube.ev.on('auth', (data) => {
- if (data.status === 'AUTHORIZATION_PENDING') {
- console.info(`Hello!\nOn your phone or computer, go to ${data.verification_url} and enter the code ${data.code}`);
- } else if (data.status === 'SUCCESS') {
- fs.writeFileSync(creds_path, JSON.stringify(data.credentials));
- console.info('Successfully signed-in, enjoy!');
- }
- });
-
- youtube.ev.on('update-credentials', (data) => {
- fs.writeFileSync(creds_path, JSON.stringify(data.credentials));
- console.info('Credentials updated!', data);
- });
-
- await youtube.signIn(creds);
-
- const search = await youtube.search('Looking for life on Mars - documentary');
- console.info('Search results:', search);
-
- const video = await youtube.getDetails(search.videos[0].id).catch((error) => error);
- console.info('Video details:', video);
-
- if (youtube.logged_in) {
- const myNotifications = await youtube.getNotifications();
- console.info('My notifications:', myNotifications);
-
- const like = await video.like();
- if (like.success) {
- console.info('Video marked as liked!');
- }
-
- const dislike = await video.dislike();
- if (dislike.success) {
- console.info('Video marked as disliked!');
- }
-
- const removeDislikeOrLike = await video.removeLike();
- if (removeDislikeOrLike.success) {
- console.info('Removed the dislike/like!')
- }
-
- const myComment = await video.comment('Haha, nice!');
- if (myComment.success) {
- console.info('Comment successfully posted!')
- }
-
- const subscribe = await video.subscribe();
- if (subscribe.success) {
- console.info('Just subscribed to', video.metadata.channel_name + '!');
- }
-
- const unsubscribe = await video.unsubscribe();
- if (unsubscribe.success) {
- console.info('Just unsubscribed from', video.metadata.channel_name + ' :(');
- }
- }
-
- // Downloading videos:
- const stream = youtube.download(search.videos[0].id, {
- format: 'mp4', // Optional, ignored when type is set to audio and defaults to mp4, and I recommend to leave it as it is
- quality: '360p', // if a video doesn't have a specific quality it'll fall back to 360p, this is ignored when type is set to audio
- type: 'videoandaudio' // can be “video”, “audio” and “videoandaudio”
- });
-
- stream.pipe(fs.createWriteStream(`./${search.videos[0].id}.mp4`));
-
- stream.on('start', () => {
- console.info('[DOWNLOADER]', 'Starting download now!');
- });
-
- stream.on('info', (info) => {
- console.info('[DOWNLOADER]', `Downloading ${info.video_details.title} by ${info.video_details.metadata.channel_name}`);
- });
-
- stream.on('end', () => {
- console.info('[DOWNLOADER]', 'Done!');
- });
-
- stream.on('error', (err) => console.error('[ERROR]', err));
-}
-
+'use strict';
+
+const fs = require('fs');
+const Innertube = require('..');
+const creds_path = './yt_oauth_creds.json';
+const creds = fs.existsSync(creds_path) && JSON.parse(fs.readFileSync(creds_path).toString()) || {};
+
+async function start() {
+ const youtube = await new Innertube();
+
+ youtube.ev.on('auth', (data) => {
+ if (data.status === 'AUTHORIZATION_PENDING') {
+ console.info(`Hello!\nOn your phone or computer, go to ${data.verification_url} and enter the code ${data.code}`);
+ } else if (data.status === 'SUCCESS') {
+ fs.writeFileSync(creds_path, JSON.stringify(data.credentials));
+ console.info('Successfully signed-in, enjoy!');
+ }
+ });
+
+ youtube.ev.on('update-credentials', (data) => {
+ fs.writeFileSync(creds_path, JSON.stringify(data.credentials));
+ console.info('Credentials updated!', data);
+ });
+
+ await youtube.signIn(creds);
+
+ const search = await youtube.search('Looking for life on Mars - documentary');
+ console.info('Search results:', search);
+
+ const video = await youtube.getDetails(search.videos[0].id);
+ console.info('Video details:', video);
+
+ if (youtube.logged_in) {
+ const myNotifications = await youtube.getNotifications();
+ console.info('My notifications:', myNotifications);
+
+ const like = await video.like();
+ if (like.success) {
+ console.info('Video marked as liked!');
+ }
+
+ const dislike = await video.dislike();
+ if (dislike.success) {
+ console.info('Video marked as disliked!');
+ }
+
+ const removeDislikeOrLike = await video.removeLike();
+ if (removeDislikeOrLike.success) {
+ console.info('Removed the dislike/like!')
+ }
+
+ const myComment = await video.comment('Haha, nice!');
+ if (myComment.success) {
+ console.info('Comment successfully posted!')
+ }
+
+ const subscribe = await video.subscribe();
+ if (subscribe.success) {
+ console.info('Just subscribed to', video.metadata.channel_name + '!');
+ }
+
+ const unsubscribe = await video.unsubscribe();
+ if (unsubscribe.success) {
+ console.info('Just unsubscribed from', video.metadata.channel_name + ' :(');
+ }
+ }
+
+ // Downloading videos:
+ const stream = youtube.download(search.videos[0].id, {
+ format: 'mp4', // Optional, ignored when type is set to audio and defaults to mp4, and I recommend to leave it as it is
+ quality: '360p', // if a video doesn't have a specific quality it'll fall back to 360p, this is ignored when type is set to audio
+ type: 'videoandaudio' // can be “video”, “audio” and “videoandaudio”
+ });
+
+ stream.pipe(fs.createWriteStream(`./${search.videos[0].id}.mp4`));
+
+ stream.on('start', () => {
+ console.info('[DOWNLOADER]', 'Starting download now!');
+ });
+
+ stream.on('info', (info) => {
+ console.info('[DOWNLOADER]', `Downloading ${info.video_details.title} by ${info.video_details.metadata.channel_name}`);
+ });
+
+ stream.on('end', () => {
+ console.info('[DOWNLOADER]', 'Done!');
+ });
+
+ stream.on('error', (err) => console.error('[ERROR]', err));
+}
+
start();
\ No newline at end of file
diff --git a/lib/Innertube.js b/lib/Innertube.js
index 443af5c1..432cb8b0 100644
--- a/lib/Innertube.js
+++ b/lib/Innertube.js
@@ -2,17 +2,20 @@
const Axios = require('axios');
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('./Sig');
-const EventEmitter = require('events');
+const Parser = require('./parser');
const CancelToken = Axios.CancelToken;
+const EventEmitter = require('events');
+
+const OAuth = require('./core/OAuth');
+const Player = require('./core/Player');
+const Actions = require('./core/Actions');
+const Livechat = require('./core/Livechat');
+
+const Utils = require('./utils/Utils');
+const Constants = require('./utils/Constants');
+
+const NToken = require('./deciphers/NToken');
+const SigDecipher = require('./deciphers/Sig');
class Innertube {
#player;
@@ -36,11 +39,11 @@ class Innertube {
async #init() {
const response = await Axios.get(Constants.URLS.YT_BASE, Constants.DEFAULT_HEADERS(this)).catch((error) => error);
if (response instanceof Error) throw new Utils.InnertubeError('Could not retrieve Innertube session', { status_code: response.status });
-
+
const data = JSON.parse(`{${Utils.getStringBetweenStrings(response.data, 'ytcfg.set({', '});') || ''}}`);
-
+
if (data.INNERTUBE_CONTEXT) {
- this.key = data.INNERTUBE_API_KEY;
+ this.key = data.INNERTUBE_API_KEY;
this.version = data.INNERTUBE_API_VERSION;
this.context = data.INNERTUBE_CONTEXT;
@@ -60,12 +63,12 @@ class Innertube {
this.#player = new Player(this);
await this.#player.init();
-
+
if (this.logged_in && this.cookie.length) {
this.auth_apisid = Utils.getStringBetweenStrings(this.cookie, 'PAPISID=', ';');
this.auth_apisid = Utils.generateSidAuth(this.auth_apisid);
}
-
+
// Axios instances
this.YTRequester = Axios.create({
baseURL: Constants.URLS.YT_BASE_API + this.version,
@@ -73,21 +76,21 @@ class Innertube {
headers: Constants.INNERTUBE_HEADERS({ session: this, ytmusic: false }),
params: { key: this.key }
});
-
+
this.YTMRequester = Axios.create({
baseURL: Constants.URLS.YT_MUSIC_BASE_API + this.version,
timeout: 15000,
headers: Constants.INNERTUBE_HEADERS({ session: this, ytmusic: true }),
params: { key: this.key }
});
-
+
this.#initMethods();
} else {
this.#retry_count += 1;
if (this.#retry_count >= 10)
- throw new Utils.ParsingError('No InnerTubeContext shell provided in ytconfig.', {
- data_snippet: response.data.slice(0, 300),
- status_code: response.status
+ throw new Utils.ParsingError('No InnerTubeContext shell provided in ytconfig.', {
+ data_snippet: response.data.slice(0, 300),
+ status_code: response.status || 0
});
return this.#init();
}
@@ -239,16 +242,16 @@ class Innertube {
*/
async #setSetting(setting_id, type, new_value) {
const response = await Actions.browse(this, type);
-
- const contents = response.data.contents.twoColumnBrowseResultsRenderer.tabs[0]
- .tabRenderer.content.sectionListRenderer.contents[1]
- .itemSectionRenderer.contents.find((content) => content.settingsOptionsRenderer.options)
- .settingsOptionsRenderer.options;
+
+ const contents = ({
+ account_notifications: () => Utils.findNode(response.data, 'contents', 'Your preferences', 13, false).options,
+ account_privacy: () => Utils.findNode(response.data, 'contents', 'settingsSwitchRenderer', 13, false).options
+ })[type.trim()]();
const option = contents.find((option) => option.settingsSwitchRenderer.enableServiceEndpoint.setSettingEndpoint.settingItemIdForClient == setting_id);
const setting_item_id = option.settingsSwitchRenderer.enableServiceEndpoint.setSettingEndpoint.settingItemId;
- const set_setting = await Actions.account(this, 'account/set_setting', { new_value, setting_item_id });
+ const set_setting = await Actions.account(this, 'account/set_setting', { new_value: type == 'account_privacy' ? !new_value : new_value, setting_item_id });
return {
success: set_setting.success,
@@ -279,16 +282,16 @@ class Innertube {
this.access_token = auth_info.access_token;
this.refresh_token = auth_info.refresh_token;
this.logged_in = true;
-
+
// API key is not needed if logged in via OAuth
delete this.YTRequester.defaults.params.key;
delete this.YTMRequester.defaults.params.key;
-
+
// Update default headers
this.YTRequester.defaults.headers = Constants.INNERTUBE_HEADERS({ session: this, ytmusic: false });
this.YTMRequester.defaults.headers = Constants.INNERTUBE_HEADERS({ session: this, ytmusic: true });
- resolve();
+ resolve();
} else {
oauth.on('auth', (data) => {
if (data.status === 'SUCCESS') {
@@ -296,10 +299,10 @@ class Innertube {
this.access_token = data.credentials.access_token;
this.refresh_token = data.credentials.refresh_token;
this.logged_in = true;
-
+
delete this.YTRequester.defaults.params.key;
delete this.YTMRequester.defaults.params.key;
-
+
this.YTRequester.defaults.headers = Constants.INNERTUBE_HEADERS({ session: this, ytmusic: false });
this.YTMRequester.defaults.headers = Constants.INNERTUBE_HEADERS({ session: this, ytmusic: true });
@@ -319,9 +322,9 @@ class Innertube {
async getAccountInfo() {
const response = await Actions.account(this, 'account/account_menu');
if (!response.success) throw new Utils.InnertubeError('Could not get account info', response);
-
+
const menu = Utils.findNode(response, 'actions', 'multiPageMenuRenderer', 6, false);
-
+
return {
name: menu.header.activeAccountHeaderRenderer.accountName.simpleText,
photo: menu.header.activeAccountHeaderRenderer.accountPhoto.thumbnails,
@@ -329,7 +332,7 @@ class Innertube {
language: menu.sections[1].multiPageMenuSectionRenderer.items[1].compactLinkRenderer.subtitle.simpleText
}
}
-
+
/**
* Searches on YouTube.
*
@@ -345,13 +348,13 @@ class Innertube {
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 Utils.InnertubeError('Could not search on YouTube', response);
-
- const parsed_data = new Parser(this, response.data, {
+
+ const parsed_data = new Parser(this, response.data, {
client: options.client,
data_type: 'SEARCH',
- query
+ query
}).parse();
-
+
return parsed_data;
}
@@ -376,14 +379,14 @@ class Innertube {
});
} else if (options.client == 'YTMUSIC') {
const response = await Actions.music(this, 'get_search_suggestions', { input });
-
+
if (!response.success) throw new Utils.InnertubeError('Could not get search suggestions', response);
if (!response.data.contents) return [];
const contents = response.data.contents[0].searchSuggestionsSectionRenderer.contents;
return contents.map((item) => {
let suggestion;
-
+
item.historySuggestionRenderer &&
(suggestion = item.historySuggestionRenderer.suggestion) ||
(suggestion = item.searchSuggestionRenderer.suggestion);
@@ -397,20 +400,20 @@ class Innertube {
}
/**
- * Gets details for a video.
- *
- * @param {string} video_id - The id of the video.
- * @return {Promise.<{ title: string; description: string; thumbnail: []; metadata: {} }>}
- */
+ * Gets details for a video.
+ *
+ * @param {string} video_id - The id of the video.
+ * @return {Promise.<{ title: string; description: string; thumbnail: []; metadata: {} }>}
+ */
async getDetails(video_id) {
if (!video_id) throw new Utils.MissingParamError('Video id is missing');
const data = await Actions.getVideoInfo(this, { id: video_id });
const continuation = await Actions.next(this, { video_id });
data.continuation = continuation.data;
-
+
const details = new Parser(this, data, { client: 'YOUTUBE', data_type: 'VIDEO_INFO' }).parse();
-
+
// Functions
details.like = () => Actions.engage(this, 'like/like', { video_id });
details.dislike = () => Actions.engage(this, 'like/dislike', { video_id });
@@ -424,7 +427,7 @@ class Innertube {
return details;
}
-
+
/**
* Gets info about a given channel. (WIP)
*
@@ -434,27 +437,27 @@ class Innertube {
async getChannel(id) {
const response = await Actions.browse(this, 'channel', { browse_id: id });
if (!response.success) throw new Utils.InnertubeError('Could not retrieve channel info.', response);
-
+
const tabs = response.data.contents.twoColumnBrowseResultsRenderer.tabs;
const metadata = response.data.metadata;
-
+
const home_tab = tabs.find((tab) => tab.tabRenderer.title == 'Home');
const home_contents = home_tab.tabRenderer.content.sectionListRenderer.contents;
const home_shelves = [];
-
+
home_contents.forEach((content) => {
- if(!content.itemSectionRenderer) return;
-
+ if (!content.itemSectionRenderer) return;
+
const contents = content.itemSectionRenderer.contents[0];
-
+
const list = contents?.shelfRenderer?.content.horizontalListRenderer;
if (!list) return; // For now we'll support only videos & playlists; TODO: Handle featured channels
-
+
const shelf = {
title: contents.shelfRenderer.title.runs[0].text,
content: []
};
-
+
shelf.content = list.items.map((item) => {
const renderer = item.gridVideoRenderer || item.gridPlaylistRenderer;
if (renderer.videoId) {
@@ -487,7 +490,7 @@ class Innertube {
});
home_shelves.push(shelf);
});
-
+
return {
title: metadata.channelMetadataRenderer.title,
description: metadata.channelMetadataRenderer.description,
@@ -501,8 +504,8 @@ class Innertube {
},
content: {
// Home page of the channel, always available in the first request.
- home_page: home_shelves,
-
+ home_page: home_shelves,
+
// Functions— these will need additional requests and will possibly use the parser.
getVideos: () => {},
getPlaylists: () => {},
@@ -512,15 +515,16 @@ class Innertube {
}
}
}
-
+
/**
- * Retrieves the lyrics for a given song if available.
- *
- * @param {string} video_id
- * @returns {Promise.} Song lyrics
- */
+ * Retrieves the lyrics for a given song if available.
+ *
+ * @param {string} video_id
+ * @returns {Promise.} Song lyrics
+ */
async getLyrics(video_id) {
const continuation = await Actions.next(this, { video_id: video_id, ytmusic: true });
+ if (!continuation.success) throw new Utils.InnertubeError('Could not retrieve lyrics', continuation);
const lyrics_tab = continuation.data.contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer
.watchNextTabbedResultsRenderer.tabs.find((obj) => obj.tabRenderer.title == 'Lyrics');
@@ -549,59 +553,59 @@ class Innertube {
}
/**
- * Gets the comments section of a video.
- *
- * @param {string} video_id - The id of the video.
- * @param {string} [data] - Video data and continuation token (optional).
- * @return {Promise.<[{ comments: []; comment_count?: string }]>
- */
+ * Gets the comments section of a video.
+ *
+ * @param {string} video_id - The id of the video.
+ * @param {string} [data] - Video data and continuation token (optional).
+ * @return {Promise.<[{ comments: []; comment_count?: string }]>
+ */
async getComments(video_id, data = {}) {
let comment_section_token;
-
+
if (!data.token) {
const continuation = await Actions.next(this, { video_id });
if (!continuation.success) throw new Utils.InnertubeError('Could not fetch comments section', continuation);
-
+
const contents = Utils.findNode(continuation.data, 'contents', 'comments-section', 5);
const item_section_renderer = contents.find((item) => item.itemSectionRenderer).itemSectionRenderer;
- comment_section_token = item_section_renderer?.contents[0]?.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
-
+ comment_section_token = item_section_renderer?.contents[0]?.continuationItemRenderer?.continuationEndpoint.continuationCommand.token;
+
const secondary_info_renderer = contents.find((item) => item.videoSecondaryInfoRenderer).videoSecondaryInfoRenderer;
data.channel_id = secondary_info_renderer.owner.videoOwnerRenderer.navigationEndpoint.browseEndpoint.browseId;
}
-
+
const response = await Actions.next(this, { continuation_token: comment_section_token || data.token });
if (!response.success) throw new Utils.InnertubeError('Could not fetch comments section', response);
-
+
const comments_section = { comments: [] };
!data.token && (comments_section.comment_count = response.data?.onResponseReceivedEndpoints[0]?.reloadContinuationItemsCommand?.continuationItems[0]?.commentsHeaderRenderer?.countText.runs[0]?.text || 'N/A');
-
+
let continuation_token;
- !data.token &&
+ !data.token &&
(continuation_token = response.data?.onResponseReceivedEndpoints[1]?.reloadContinuationItemsCommand?.continuationItems
- ?.find((item) => item.continuationItemRenderer)?.continuationItemRenderer?.continuationEndpoint?.continuationCommand.token) ||
+ ?.find((item) => item.continuationItemRenderer)?.continuationItemRenderer?.continuationEndpoint?.continuationCommand.token) ||
((continuation_token = response.data?.onResponseReceivedEndpoints[0]?.appendContinuationItemsAction?.continuationItems
- ?.find((item) => item.continuationItemRenderer)?.continuationItemRenderer?.continuationEndpoint?.continuationCommand.token) ||
- (continuation_token = response.data?.onResponseReceivedEndpoints[0]?.appendContinuationItemsAction?.continuationItems
- ?.find((item) => item.continuationItemRenderer)?.continuationItemRenderer?.button.buttonRenderer.command.continuationCommand.token));
-
+ ?.find((item) => item.continuationItemRenderer)?.continuationItemRenderer?.continuationEndpoint?.continuationCommand.token) ||
+ (continuation_token = response.data?.onResponseReceivedEndpoints[0]?.appendContinuationItemsAction?.continuationItems
+ ?.find((item) => item.continuationItemRenderer)?.continuationItemRenderer?.button.buttonRenderer.command.continuationCommand.token));
+
continuation_token && (comments_section.getContinuation = () => this.getComments(video_id, { token: continuation_token, channel_id: data.channel_id }));
let contents;
!data.token && (contents = response.data.onResponseReceivedEndpoints[1].reloadContinuationItemsCommand.continuationItems) ||
(contents = response.data.onResponseReceivedEndpoints[0].appendContinuationItemsAction.continuationItems);
-
+
contents.forEach((content) => {
const thread = content?.commentThreadRenderer?.comment.commentRenderer || content?.commentRenderer;
if (!thread) return;
-
+
const replies_token = content?.commentThreadRenderer?.replies?.commentRepliesRenderer.contents
.find((content) => content.continuationItemRenderer.continuationEndpoint)
- .continuationItemRenderer.continuationEndpoint.continuationCommand.token;
-
+ .continuationItemRenderer.continuationEndpoint.continuationCommand.token;
+
const like_btn = thread?.actionButtons?.commentActionButtonsRenderer.likeButton;
const dislike_btn = thread?.actionButtons?.commentActionButtonsRenderer.dislikeButton;
-
+
const comment = {
text: thread.contentText.runs.map((t) => t.text).join(' '),
author: {
@@ -624,9 +628,9 @@ class Innertube {
reply: (text) => Actions.engage(this, 'comment/create_comment_reply', { text, comment_id: thread.commentId, video_id }),
getReplies: () => this.getComments(video_id, { token: replies_token, channel_id: data.channel_id })
};
-
+
!replies_token && (delete comment.getReplies);
-
+
comments_section.comments.push(comment);
});
@@ -640,28 +644,29 @@ class Innertube {
async getHistory() {
const response = await Actions.browse(this, 'history');
if (!response.success) throw new Utils.InnertubeError('Could not retrieve watch history', response);
-
- const contents = Utils.findNode(response, 'contents', 'videoRenderer', 9, false)
-
+
+ const contents = Utils.findNode(response, 'contents', 'videoRenderer', 9, false)
+
const history = { items: [] };
-
+
const parseItems = (contents) => {
contents.forEach((section) => {
if (!section.itemSectionRenderer) return;
-
- const section_title = section.itemSectionRenderer.header.itemSectionHeaderRenderer.title.simpleText;
+
+ const header = section.itemSectionRenderer.header.itemSectionHeaderRenderer.title;
+ const section_title = header?.simpleText || header?.runs.map((run) => run.text).join('');
const contents = section.itemSectionRenderer.contents;
-
+
const section_items = contents.map((item) => {
return {
- id: item.videoRenderer.videoId,
+ id: item?.videoRenderer?.videoId,
title: item?.videoRenderer?.title?.runs?.map((run) => run.text).join(' '),
description: item?.videoRenderer?.descriptionSnippet?.runs[0]?.text || 'N/A',
channel: {
id: item?.videoRenderer?.shortBylineText?.runs[0]?.navigationEndpoint?.browseEndpoint?.browseId,
name: item?.videoRenderer?.shortBylineText?.runs[0]?.text || 'N/A',
url: `${Constants.URLS.YT_BASE}${item?.videoRenderer?.shortBylineText?.runs[0]?.navigationEndpoint?.browseEndpoint?.canonicalBaseUrl}`
- },
+ },
metadata: {
view_count: item?.videoRenderer?.viewCountText?.simpleText || 'N/A',
short_view_count_text: {
@@ -680,45 +685,45 @@ class Innertube {
}
};
});
-
+
history.items.push({
date: section_title,
videos: section_items
});
});
-
+
history.getContinuation = async () => {
const citem = contents.find((item) => item.continuationItemRenderer);
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
-
+
const response = await Actions.browse(this, 'continuation', { ctoken });
if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response);
-
+
history.items = [];
-
+
return parseItems(response.data.onResponseReceivedActions[0].appendContinuationItemsAction.continuationItems);
}
-
+
return history;
}
-
+
return parseItems(contents);
}
/**
- * Returns YouTube's home feed (aka recommendations).
- * @returns {Promise.<{ videos: [{ id: string; title: string; description: string; channel: string; metadata: object }] }>}
- */
+ * Returns YouTube's home feed (aka recommendations).
+ * @returns {Promise.<{ videos: [{ id: string; title: string; description: string; channel: string; metadata: object }] }>}
+ */
async getHomeFeed() {
const response = await Actions.browse(this, 'home_feed');
if (!response.success) throw new Utils.InnertubeError('Could not retrieve home feed', response);
- const contents = Utils.findNode(response, 'contents', 'videoRenderer', 9, false)
-
+ const contents = Utils.findNode(response, 'contents', 'videoRenderer', 9, false)
+
const parseItems = (contents) => {
const videos = contents.map((item) => {
const content = item.richItemRenderer && item.richItemRenderer.content.videoRenderer &&
- item.richItemRenderer.content;
+ item.richItemRenderer.content;
if (content) return {
id: content.videoRenderer.videoId,
@@ -728,7 +733,7 @@ class Innertube {
id: content?.videoRenderer?.shortBylineText?.runs[0]?.navigationEndpoint?.browseEndpoint?.browseId,
name: content?.videoRenderer?.shortBylineText?.runs[0]?.text || 'N/A',
url: `${Constants.URLS.YT_BASE}${content?.videoRenderer?.shortBylineText?.runs[0]?.navigationEndpoint?.browseEndpoint?.canonicalBaseUrl}`
- },
+ },
metadata: {
view_count: content?.videoRenderer?.viewCountText?.simpleText || 'N/A',
short_view_count_text: {
@@ -748,35 +753,35 @@ class Innertube {
}
}
}).filter((item) => item);
-
+
const getContinuation = async () => {
const citem = contents.find((item) => item.continuationItemRenderer);
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
-
+
const response = await Actions.browse(this, 'continuation', { ctoken });
if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response);
-
+
return parseItems(response.data.onResponseReceivedActions[0].appendContinuationItemsAction.continuationItems);
}
-
+
return { videos, getContinuation };
}
-
+
return parseItems(contents);
}
/**
- * Returns your subscription feed.
- * @returns {Promise.<{ items: [{ date: string; videos: [] }] }>}
- */
+ * Returns your subscription feed.
+ * @returns {Promise.<{ items: [{ date: string; videos: [] }] }>}
+ */
async getSubscriptionsFeed() {
const response = await Actions.browse(this, 'subscriptions_feed');
if (!response.success) throw new Utils.InnertubeError('Could not retrieve subscriptions feed', response);
-
+
const contents = Utils.findNode(response, 'contents', 'contents', 9, false);
-
+
const subsfeed = { items: [] };
-
+
const parseItems = (contents) => {
contents.forEach((section) => {
if (!section.itemSectionRenderer) return;
@@ -784,7 +789,7 @@ class Innertube {
const section_contents = section.itemSectionRenderer.contents[0];
const section_title = section_contents.shelfRenderer.title.runs[0].text;
const section_items = section_contents.shelfRenderer.content.gridRenderer.items;
-
+
const items = section_items.map((item) => {
return {
id: item.gridVideoRenderer.videoId,
@@ -793,7 +798,7 @@ class Innertube {
id: item?.gridVideoRenderer?.shortBylineText?.runs[0]?.navigationEndpoint?.browseEndpoint?.browseId,
name: item?.gridVideoRenderer?.shortBylineText?.runs[0]?.text || 'N/A',
url: `${Constants.URLS.YT_BASE}${item?.gridVideoRenderer?.shortBylineText?.runs[0]?.navigationEndpoint?.browseEndpoint?.canonicalBaseUrl}`
- },
+ },
metadata: {
view_count: item?.gridVideoRenderer?.viewCountText?.simpleText || 'N/A',
short_view_count_text: {
@@ -808,29 +813,29 @@ class Innertube {
}
};
});
-
+
subsfeed.items.push({
- date: section_title,
+ date: section_title,
videos: items
});
});
-
+
subsfeed.getContinuation = async () => {
const citem = contents.find((item) => item.continuationItemRenderer);
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
-
+
const response = await Actions.browse(this, 'continuation', { ctoken });
if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response);
-
+
const ccontents = Utils.findNode(response.data, 'onResponseReceivedActions', 'itemSectionRenderer', 4, false);
subsfeed.items = [];
-
+
return parseItems(ccontents);
}
-
+
return subsfeed;
};
-
+
return parseItems(contents);
}
@@ -844,7 +849,7 @@ class Innertube {
const contents = response.data.actions[0].openPopupAction.popup.multiPageMenuRenderer.sections[0];
if (!contents.multiPageMenuNotificationSectionRenderer) throw new Utils.InnertubeError('No notifications', response);
-
+
const parseItems = (items) => {
const parsed_items = items.map((notification) => {
if (!notification.notificationRenderer) return;
@@ -860,20 +865,20 @@ class Innertube {
notification_id: notification.notificationId,
};
}).filter((notification) => notification);
-
+
const getContinuation = async () => {
const citem = items.find((item) => item.continuationItemRenderer);
const ctoken = citem?.continuationItemRenderer?.continuationEndpoint?.getNotificationMenuEndpoint?.ctoken;
-
+
const response = await Actions.notifications(this, 'get_notification_menu', { ctoken });
if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response);
-
+
return parseItems(response.data.actions[0].appendContinuationItemsAction.continuationItems);
}
-
+
return { items: parsed_items, getContinuation };
}
-
+
return parseItems(contents.multiPageMenuNotificationSectionRenderer.items);
}
@@ -911,9 +916,9 @@ class Innertube {
const url_components = new URL(format.url);
url_components.searchParams.set('cver', this.context.client.clientVersion);
url_components.searchParams.set('ratebypass', 'yes');
-
+
if (url_components.searchParams.get('n')) {
- url_components.searchParams.set('n', new NToken(this.#player.ntoken_sc).transform(url_components.searchParams.get('n')));
+ url_components.searchParams.set('n', new NToken(this.#player.ntoken_sc, url_components.searchParams.get('n')).transform());
}
format.url = url_components.toString();
@@ -1011,7 +1016,7 @@ class Innertube {
if (!format)
return stream.emit('error', { message: 'Could not find any suitable format.', type: 'FORMAT_UNAVAILABLE' });
- const video_details = new Parser(this, video_data, { client: 'YOUTUBE', data_type: 'VIDEO_INFO', desktop_v: true }).parse();
+ const video_details = new Parser(this, video_data, { client: 'YOUTUBE', data_type: 'VIDEO_INFO' }).parse();
stream.emit('info', { video_details, selected_format: format, formats });
if (options.type == 'videoandaudio' && !options.range) {
@@ -1129,4 +1134,4 @@ class Innertube {
}
}
-module.exports = Innertube;
+module.exports = Innertube;
\ No newline at end of file
diff --git a/lib/Parser.js b/lib/Parser.js
deleted file mode 100644
index 0cb965cd..00000000
--- a/lib/Parser.js
+++ /dev/null
@@ -1,332 +0,0 @@
-'use strict';
-
-const Utils = require('./Utils');
-const Actions = require('./Actions');
-const Constants = require('./Constants');
-
-/**
- * Takes raw data from the Innertube API and refines it.
- * Mainly used for video data and search results as they're 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.#processSearch(),
- PLAYLIST: () => this.#processPlaylist(),
- VIDEO_INFO: () => this.#processVideoInfo()
- })[this.args.data_type]() : ({
- SEARCH: () => this.#processMusicSearch(),
- PLAYLIST: () => this.#processMusicPlaylist()
- })[this.args.data_type]();
- }
-
- #processSearch() {
- const search = {};
-
- const contents = Utils.findNode(this.data, 'contents', 'contents', 5);
-
- const parseItems = (contents) => {
- const items = contents[0].itemSectionRenderer.contents;
-
- search.query = items[0]?.showingResultsForRenderer?.originalQuery?.simpleText || this.args.query;
- search.corrected_query = items[0]?.showingResultsForRenderer?.correctedQueryEndpoint?.searchEndpoint?.query || this.args.query;
- search.estimated_results = parseInt(this.data.estimatedResults);
-
- search.videos = items.map((data) => {
- const renderer = data.videoRenderer || data.compactVideoRenderer;
- if (!renderer) return;
-
- return {
- id: renderer.videoId,
- title: renderer.title.runs[0].text,
- description: renderer?.detailedMetadataSnippets && renderer?.detailedMetadataSnippets[0].snippetText.runs.map((item) => item.text).join('') || 'N/A',
- channel: {
- id: renderer?.ownerText?.runs[0]?.navigationEndpoint?.browseEndpoint?.browseId,
- name: renderer?.ownerText?.runs[0]?.text,
- url: `${Constants.URLS.YT_BASE}${renderer.ownerText.runs[0].navigationEndpoint?.browseEndpoint?.canonicalBaseUrl}`
- },
- url: `https://youtu.be/${renderer.videoId}`,
- metadata: {
- view_count: renderer?.viewCountText?.simpleText || 'N/A',
- short_view_count_text: {
- simple_text: renderer?.shortViewCountText?.simpleText || 'N/A',
- accessibility_label: renderer?.shortViewCountText?.accessibility?.accessibilityData?.label || 'N/A',
- },
- thumbnails: renderer?.thumbnail.thumbnails,
- duration: {
- seconds: Utils.timeToSeconds(renderer?.lengthText?.simpleText || '0'),
- simple_text: renderer?.lengthText?.simpleText || 'N/A',
- accessibility_label: renderer?.lengthText?.accessibility?.accessibilityData?.label || 'N/A'
- },
- published: renderer?.publishedTimeText?.simpleText || 'N/A',
- badges: renderer?.badges?.map((item) => item.metadataBadgeRenderer.label) || [],
- owner_badges: renderer?.ownerBadges?.map((item) => item.metadataBadgeRenderer.tooltip) || []
- }
- };
- }).filter((res) => res);
-
- search.getContinuation = async () => {
- const citem = contents.find((item) => item.continuationItemRenderer);
- const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
-
- const response = await Actions.search(this.session, 'YOUTUBE', { ctoken });
- if (!response.success) throw new Utils.InnertubeError('Could not get continuation', response);
-
- const continuation_items = Utils.findNode(response.data, 'onResponseReceivedCommands', 'itemSectionRenderer', 4, false);
- return parseItems(continuation_items);
- };
-
- return search;
- }
-
- return parseItems(contents);
- }
-
- #processPlaylist() {
- const details = this.data.sidebar.playlistSidebarRenderer.items[0];
-
- const metadata = {
- title: this.data.metadata.playlistMetadataRenderer.title,
- description: details.playlistSidebarPrimaryInfoRenderer.description.simpleText || 'N/A',
- total_items: details.playlistSidebarPrimaryInfoRenderer.stats[0].runs[0].text,
- last_updated: details.playlistSidebarPrimaryInfoRenderer.stats[2].runs[1].text,
- views: details.playlistSidebarPrimaryInfoRenderer.stats[1].simpleText
- }
-
- const list = Utils.findNode(this.data, 'contents', 'contents', 13, false);
-
- const items = list.contents.map((item) => {
- if (item.playlistVideoRenderer)
- return {
- id: item?.playlistVideoRenderer?.videoId,
- title: item?.playlistVideoRenderer?.title?.runs[0]?.text,
- author: item?.playlistVideoRenderer?.shortBylineText?.runs[0]?.text,
- duration: {
- seconds: Utils.timeToSeconds(item?.playlistVideoRenderer?.lengthText?.simpleText || '0'),
- simple_text: item?.playlistVideoRenderer?.lengthText?.simpleText || 'N/A',
- accessibility_label: item?.playlistVideoRenderer?.lengthText?.accessibility?.accessibilityData?.label || 'N/A'
- },
- thumbnail: item?.playlistVideoRenderer?.thumbnail?.thumbnails,
- }
- });
-
- return {
- ...metadata,
- items
- }
- }
-
- #processMusicSearch() {
- const tabs = Utils.findNode(this.data, 'contents', 'tabs').tabs;
- const contents = Utils.findNode(tabs, '0', 'contents', 5);
-
- if (contents.length <= 1)
- return { songs: [], videos: [], albums: [], playlists: [] };
-
- const songs_ms = Utils.findNode(contents, 'musicShelfRenderer', 'Songs');
- const songs = songs_ms?.contents.map((item) => {
- const list_item = item.musicResponsiveListItemRenderer;
- if (list_item.playlistItemData)
- return {
- id: list_item.playlistItemData.videoId,
- title: list_item.flexColumns[0]?.musicResponsiveListItemFlexColumnRenderer.text.runs[0]?.text,
- artist: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.text,
- album: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[4]?.text,
- duration: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs
- .find((run) => /^\d+$/.test(run.text.replace(/:/g, ''))).text,
- thumbnail: list_item.thumbnail.musicThumbnailRenderer.thumbnail,
- getLyrics: () => this.session.getLyrics(list_item.playlistItemData.videoId)
- };
- }).filter((item) => item);
-
- const videos_ms = Utils.findNode(contents, 'musicShelfRenderer', 'Videos');
- const videos = videos_ms?.contents.map((item) => {
- const list_item = item.musicResponsiveListItemRenderer;
- if (list_item.playlistItemData)
- return {
- id: list_item.playlistItemData.videoId,
- title: list_item.flexColumns[0]?.musicResponsiveListItemFlexColumnRenderer.text.runs[0]?.text,
- author: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.text,
- views: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[4]?.text,
- duration: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs
- .find((run) => /^\d+$/.test(run.text.replace(/:/g, ''))).text,
- thumbnail: list_item?.thumbnail.musicThumbnailRenderer.thumbnail,
- getLyrics: () => this.session.getLyrics(list_item.playlistItemData.videoId)
- };
- }).filter((item) => item);
-
- const albums_ms = Utils.findNode(contents, 'musicShelfRenderer', 'Albums');
- const albums = albums_ms?.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,
- };
- });
-
- const playlists_ms = Utils.findNode(contents, 'musicShelfRenderer', 'Community playlists');
- const playlists = playlists_ms?.contents.map((item) => {
- const list_item = item.musicResponsiveListItemRenderer;
- const watch_playlist_endpoint = list_item.overlay.musicItemThumbnailOverlayRenderer
- .content.musicPlayButtonRenderer.playNavigationEndpoint.watchPlaylistEndpoint;
- return {
- id: watch_playlist_endpoint?.playlistId,
- title: list_item.flexColumns[0]?.musicResponsiveListItemFlexColumnRenderer.text.runs[0]?.text,
- author: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.text,
- channel_id: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.navigationEndpoint.browseEndpoint.browseId,
- total_items: parseInt(list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[4]?.text.match(/\d+/g)),
- };
- });
-
- return { songs, videos, albums, playlists };
- }
-
- #processMusicPlaylist() {
- const details = this.data.header.musicDetailHeaderRenderer;
-
- const metadata = {
- title: details?.title?.runs[0].text,
- description: details?.description?.runs?.map((run) => run.text).join('') || 'N/A',
- total_items: parseInt(details?.secondSubtitle?.runs[0].text.match(/\d+/g)),
- duration: details?.secondSubtitle?.runs[2].text,
- year: details?.subtitle?.runs[4].text
- };
-
- const contents = this.data.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents;
- const playlist_content = contents[0].musicPlaylistShelfRenderer.contents;
-
- const items = playlist_content.map((item) => {
- const item_renderer = item.musicResponsiveListItemRenderer;
- const fixed_columns = item_renderer.fixedColumns;
- const flex_columns = item_renderer.flexColumns;
-
- return {
- id: item_renderer.playlistItemData && item_renderer.playlistItemData.videoId,
- title: flex_columns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
- author: flex_columns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
- duration: fixed_columns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text,
- duration: {
- seconds: Utils.timeToSeconds(fixed_columns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text || '0'),
- simple_text: fixed_columns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text,
- },
- thumbnail: item_renderer.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails
- }
- }).filter((item) => item.id);
-
- return {
- ...metadata,
- items
- }
- }
-
- /**
- * Video data is parsed dynamically, so if youtube decides to add something new we won't have to change anything here.
- */
- #processVideoInfo() {
- const playability_status = this.data.playabilityStatus;
-
- if (playability_status.status == 'ERROR')
- throw new Error(`Could not retrieve video details: ${playability_status.status} - ${playability_status.reason}`);
-
- const details = this.data.videoDetails;
- const microformat = this.data.microformat.playerMicroformatRenderer;
- const streaming_data = this.data.streamingData;
-
- const response = {
- id: '',
- title: '',
- description: '',
- thumbnail: [],
- metadata: {}
- };
-
- const mf_raw_data = Object.entries(microformat);
- const dt_raw_data = Object.entries(details);
-
- // Extracts most of the metadata
- mf_raw_data.forEach((entry) => {
- const key = Utils.camelToSnake(entry[0]);
- if (Constants.METADATA_KEYS.includes(key)) {
- key == 'view_count' && (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];
- }
- });
-
- // Extracts extra details
- dt_raw_data.forEach((entry) => {
- const key = Utils.camelToSnake(entry[0]);
- if (Constants.BLACKLISTED_KEYS.includes(key)) return;
- if (Constants.METADATA_KEYS.includes(key)) {
- key == 'view_count' && (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]);
- }
- });
-
- // Data continuation is only required in getDetails()
- if (this.data.continuation) {
- const primary_info_renderer = this.data.continuation.contents.twoColumnWatchNextResults
- .results.results.contents.find((item) => item.videoPrimaryInfoRenderer).videoPrimaryInfoRenderer;
-
- const secondary_info_renderer = this.data.continuation.contents.twoColumnWatchNextResults
- .results.results.contents.find((item) => item.videoSecondaryInfoRenderer).videoSecondaryInfoRenderer;
-
- const like_btn = primary_info_renderer.videoActions.menuRenderer
- .topLevelButtons.find((item) => item.toggleButtonRenderer.defaultIcon.iconType == 'LIKE');
-
- const dislike_btn = primary_info_renderer.videoActions.menuRenderer
- .topLevelButtons.find((item) => item.toggleButtonRenderer.defaultIcon.iconType == 'DISLIKE');
-
- const notification_toggle_btn = secondary_info_renderer.subscribeButton.subscribeButtonRenderer
- ?.notificationPreferenceButton?.subscriptionNotificationToggleButtonRenderer;
-
- // These will always be false if logged out.
- response.metadata.is_liked = like_btn.toggleButtonRenderer.isToggled;
- response.metadata.is_disliked = dislike_btn.toggleButtonRenderer.isToggled;
- response.metadata.is_subscribed = secondary_info_renderer.subscribeButton.subscribeButtonRenderer?.subscribed || false;
-
- response.metadata.subscriber_count = secondary_info_renderer.owner.videoOwnerRenderer?.subscriberCountText?.simpleText || 'N/A';
- response.metadata.current_notification_preference = notification_toggle_btn?.states.find((state) => state.stateId == notification_toggle_btn.currentStateId)
- .state.buttonRenderer.icon.iconType || 'N/A';
-
- // Simpler version of publish_date
- response.metadata.publish_date_text = primary_info_renderer.dateText.simpleText;
-
- // Only parse like count if it's enabled
- if (response.metadata.allow_ratings) {
- response.metadata.likes = {
- count: parseInt(like_btn.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, '')),
- short_count_text: like_btn.toggleButtonRenderer.defaultText.simpleText
- };
- }
-
- response.metadata.owner_badges = secondary_info_renderer.owner.videoOwnerRenderer?.badges?.map((badge) => badge.metadataBadgeRenderer.tooltip) || [];
- }
-
- streaming_data && streaming_data.adaptiveFormats &&
- (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, '')))]) ||
- (response.metadata.available_qualities = []);
-
- return response;
- }
-}
-
-module.exports = Parser;
\ No newline at end of file
diff --git a/lib/Actions.js b/lib/core/Actions.js
similarity index 93%
rename from lib/Actions.js
rename to lib/core/Actions.js
index cc265b7d..e45a6ac9 100644
--- a/lib/Actions.js
+++ b/lib/core/Actions.js
@@ -2,8 +2,8 @@
const Uuid = require('uuid');
const Axios = require('axios');
-const Proto = require('./proto');
-const Constants = require('./Constants');
+const Proto = require('../proto');
+const Constants = require('../utils/Constants');
/**
* Performs direct interactions on YouTube.
@@ -43,14 +43,14 @@ async function engage(session, engagement_type, args = {}) {
like: () => Proto.encodeCommentActionParams(5, args.comment_id, args.video_id, args.channel_id),
dislike: () => Proto.encodeCommentActionParams(4, args.comment_id, args.video_id, args.channel_id),
})[args.comment_action]();
- data.actions = [ action ];
+ data.actions = [action];
break;
default:
throw new Utils.InnertubeError('Invalid action', action);
}
-
+
const response = await session.YTRequester.post(`/${engagement_type}`, JSON.stringify(data)).catch((error) => error);
- if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
+ if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
return {
success: true,
@@ -67,10 +67,10 @@ async function engage(session, engagement_type, args = {}) {
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
*/
async function browse(session, action, args = {}) {
- if (!session.logged_in && action != 'home_feed'
- && action !== 'lyrics' && action !== 'music_playlist'
- && action !== 'playlist')
- throw new Error('You are not signed in');
+ if (!session.logged_in && action != 'home_feed' &&
+ action !== 'lyrics' && action !== 'music_playlist' &&
+ action !== 'playlist')
+ throw new Error('You are not signed in');
const data = { context: session.context };
switch (action) {
@@ -96,7 +96,7 @@ async function browse(session, action, args = {}) {
context.client.originalUrl = Constants.URLS.YT_MUSIC;
context.client.clientVersion = Constants.YTMUSIC_VERSION;
context.client.clientName = 'WEB_REMIX';
-
+
data.context = context;
data.browseId = args.browse_id;
break;
@@ -110,10 +110,10 @@ async function browse(session, action, args = {}) {
default:
throw new Utils.InnertubeError('Invalid action', action);
}
-
+
const requester = args.ytmusic && session.YTMRequester || session.YTRequester;
const response = await requester.post('/browse', JSON.stringify(data)).catch((error) => error);
- if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
+ if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
return {
success: true,
@@ -142,14 +142,14 @@ async function account(session, action, args = {}) {
case 'account/set_setting':
data.context = session.context;
data.newValue = { boolValue: args.new_value };
- data.settingItemId = arts.setting_item_id;
+ data.settingItemId = args.setting_item_id;
break;
default:
throw new Utils.InnertubeError('Invalid action', action);
}
-
+
const response = await session.YTRequester.post(`/${action}`, JSON.stringify(data)).catch((error) => error);
- if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
+ if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
return {
success: true,
@@ -184,9 +184,9 @@ async function music(session, action, args) {
default:
throw new Utils.InnertubeError('Invalid action', action);
}
-
+
const response = await session.YTMRequester.post(`/music/${action}`, JSON.stringify(data)).catch((error) => error);
- if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
+ if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
return {
success: true,
@@ -220,17 +220,17 @@ async function search(session, client, args = {}) {
context.client.originalUrl = Constants.URLS.YT_MUSIC;
context.client.clientVersion = Constants.YTMUSIC_VERSION;
context.client.clientName = 'WEB_REMIX';
-
+
data.context = context;
data.query = args.query;
break;
default:
throw new Utils.InnertubeError('Invalid client', action);
}
-
+
const requester = client == 'YOUTUBE' && session.YTRequester || session.YTMRequester;
const response = await requester.post('/search', JSON.stringify(data)).catch((error) => error);
- if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
+ if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
return {
success: true,
@@ -268,9 +268,9 @@ async function notifications(session, action, args = {}) {
default:
throw new Utils.InnertubeError('Invalid action', action);
}
-
+
const response = await session.YTRequester.post(`/notification/${action}`, JSON.stringify(data)).catch((err) => err);
- if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
+ if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
if (action === 'modify_channel_preference') return { success: true, status_code: response.status };
return {
@@ -318,10 +318,10 @@ async function livechat(session, action, args = {}) {
default:
throw new Utils.InnertubeError('Invalid action', action);
}
-
+
const response = await session.YTRequester.post(`/${action}`, JSON.stringify(data)).catch((err) => err);
if (response instanceof Error) return { success: false, message: response.message };
-
+
return { success: true, data: response.data };
}
@@ -356,14 +356,14 @@ async function next(session, args = {}) {
data.captionsRequested = false;
}
}
-
+
const requester = args.ytmusic && session.YTMRequester || session.YTRequester;
const response = await requester.post('/next', JSON.stringify(data)).catch((error) => error);
-
+
if (response instanceof Error) return {
- success: false,
- status_code: response.response.status,
- message: response.message
+ success: false,
+ status_code: response.response?.status || 0,
+ message: response.message
};
return {
@@ -395,10 +395,10 @@ async function getVideoInfo(session, args = {}) {
*/
async function getYTSearchSuggestions(session, query) {
const response = await Axios.get(`${Constants.URLS.YT_SUGGESTIONS}search?client=firefox&ds=yt&q=${encodeURIComponent(query)}`,
- Constants.DEFAULT_HEADERS(session)).catch((error) => error);
-
+ Constants.DEFAULT_HEADERS(session)).catch((error) => error);
+
if (response instanceof Error) return {
- success: false,
+ success: false,
status_code: response.status,
message: response.message
};
@@ -410,4 +410,4 @@ async function getYTSearchSuggestions(session, query) {
};
}
-module.exports = { engage, browse, account, music, search, notifications, livechat, getVideoInfo, next, getYTSearchSuggestions };
+module.exports = { engage, browse, account, music, search, notifications, livechat, getVideoInfo, next, getYTSearchSuggestions };
\ No newline at end of file
diff --git a/lib/Livechat.js b/lib/core/Livechat.js
similarity index 98%
rename from lib/Livechat.js
rename to lib/core/Livechat.js
index e4063f67..44118a4e 100644
--- a/lib/Livechat.js
+++ b/lib/core/Livechat.js
@@ -6,10 +6,10 @@ const EventEmitter = require('events');
class Livechat extends EventEmitter {
constructor(session, token, channel_id, video_id) {
super(session);
-
- if (!token)
+
+ if (!token)
throw new Error('Could not retrieve livechat data');
-
+
this.ctoken = token;
this.session = session;
this.video_id = video_id;
@@ -20,7 +20,7 @@ class Livechat extends EventEmitter {
this.poll_intervals_ms = 1000;
this.running = true;
-
+
this.#poll();
}
diff --git a/lib/OAuth.js b/lib/core/OAuth.js
similarity index 98%
rename from lib/OAuth.js
rename to lib/core/OAuth.js
index c7c6135a..836defad 100644
--- a/lib/OAuth.js
+++ b/lib/core/OAuth.js
@@ -1,7 +1,7 @@
'use strict';
const Axios = require('axios');
-const Constants = require('./Constants');
+const Constants = require('../utils/Constants');
const EventEmitter = require('events');
const Uuid = require('uuid');
@@ -13,7 +13,7 @@ class OAuth extends EventEmitter {
this.oauth_code_url = `${Constants.URLS.YT_BASE}/o/oauth2/device/code`;
this.oauth_token_url = `${Constants.URLS.YT_BASE}/o/oauth2/token`;
-
+
this.model_name = Constants.OAUTH.MODEL_NAME;
this.grant_type = Constants.OAUTH.GRANT_TYPE;
this.scope = Constants.OAUTH.SCOPE;
@@ -177,7 +177,7 @@ class OAuth extends EventEmitter {
// Here we download the script and extract the necessary data to proceed with the auth flow.
const url_body = this.auth_script_regex.exec(yttv_response.data)[1];
const script_url = `${Constants.URLS.YT_BASE}/${url_body}`;
-
+
const response = await Axios.get(script_url, Constants.DEFAULT_HEADERS).catch((error) => error);
if (response instanceof Error) throw new Error(`Could not extract client identity: ${response.message}`);
@@ -196,4 +196,4 @@ class OAuth extends EventEmitter {
}
}
-module.exports = OAuth;
+module.exports = OAuth;
\ No newline at end of file
diff --git a/lib/Player.js b/lib/core/Player.js
similarity index 90%
rename from lib/Player.js
rename to lib/core/Player.js
index 621e5616..f15609e4 100644
--- a/lib/Player.js
+++ b/lib/core/Player.js
@@ -2,14 +2,14 @@
const Fs = require('fs');
const Axios = require('axios');
-const Utils = require('./Utils');
-const Constants = require('./Constants');
+const Utils = require('../utils/Utils');
+const Constants = require('../utils/Constants');
class Player {
constructor(session) {
this.session = session;
this.player_name = Utils.getStringBetweenStrings(this.session.player_url, '/player/', '/');
- this.tmp_cache_dir = __dirname.slice(0, -3) + 'cache';
+ this.tmp_cache_dir = __dirname.slice(0, -8) + 'cache';
}
async init() {
@@ -28,7 +28,7 @@ class Player {
// 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) { }
+ } catch (err) {}
this.sig_decipher_sc = this.#getSigDecipherCode(response.data);
this.ntoken_sc = this.#getNEncoder(response.data);
diff --git a/lib/NToken.js b/lib/deciphers/NToken.js
similarity index 85%
rename from lib/NToken.js
rename to lib/deciphers/NToken.js
index cacc76e3..d9562ed2 100644
--- a/lib/NToken.js
+++ b/lib/deciphers/NToken.js
@@ -1,23 +1,20 @@
'use strict';
-const Utils = require('./Utils');
-const Constants = require('./Constants');
+const Utils = require('../utils/Utils');
+const Constants = require('../utils/Constants');
class NToken {
- constructor(raw_code) {
+ constructor(raw_code, n) {
+ this.n = n;
this.raw_code = raw_code;
- this.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('');
+ transform() {
+ let n_token = this.n.split('');
try {
let transformations = this.#getTransformationData();
@@ -42,12 +39,12 @@ class NToken {
});
// Fills all placeholders with the transformations array
- const placeholder_indexes = [...this.raw_code.matchAll(this.placeholders_regex)].map((item) => parseInt(item[1]));
+ const placeholder_indexes = [...this.raw_code.matchAll(Constants.NTOKEN_REGEX.PLACEHOLDERS)].map((item) => parseInt(item[1]));
placeholder_indexes.forEach((i) => transformations[i] = transformations);
// Parses and emulates calls to the functions of the transformations array
const function_calls = [...Utils.getStringBetweenStrings(this.raw_code.replace(/\n/g, ''), 'try{', '}catch')
- .matchAll(this.calls_regex)].map((params) => ({ index: params[1], params: params[2] }));
+ .matchAll(Constants.NTOKEN_REGEX.CALLS)].map((params) => ({ index: params[1], params: params[2] }));
function_calls.forEach((data) => {
const param_index = data.params.split(',').map((param) => param.match(/c\[(.*?)\]/)[1]);
@@ -55,8 +52,8 @@ class NToken {
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;
+ console.error(`Could not transform n-token (${this.n}), download may be throttled:`, err.message);
+ return this.n;
}
return n_token.join('');
}
@@ -80,14 +77,14 @@ class NToken {
*/
#translate1(arr, token, is_reverse_base64) {
const characters = is_reverse_base64 && Constants.BASE64_DIALECT.REVERSE || Constants.BASE64_DIALECT.NORMAL;
- arr.forEach(function (char, index, loc) {
+ arr.forEach(function(char, index, loc) {
this.push(loc[index] = characters[(characters.indexOf(char) - characters.indexOf(this[index]) + 64) % characters.length]);
}, token.split(''));
}
#translate2(arr, token, characters) {
let chars_length = characters.length;
- arr.forEach(function (char, index, loc) {
+ arr.forEach(function(char, index, loc) {
this.push(loc[index] = characters[(characters.indexOf(char) - characters.indexOf(this[index]) + index + chars_length--) % characters.length]);
}, token.split(''));
}
diff --git a/lib/Sig.js b/lib/deciphers/Sig.js
similarity index 96%
rename from lib/Sig.js
rename to lib/deciphers/Sig.js
index 3f836da0..10ed36b2 100644
--- a/lib/Sig.js
+++ b/lib/deciphers/Sig.js
@@ -11,8 +11,8 @@ class SigDecipher {
}
/**
- * Deciphers signature.
- */
+ * Deciphers signature.
+ */
decipher() {
const args = QueryString.parse(this.url);
const functions = this.#getFunctions();
diff --git a/lib/parser/index.js b/lib/parser/index.js
new file mode 100644
index 00000000..c8901de5
--- /dev/null
+++ b/lib/parser/index.js
@@ -0,0 +1,255 @@
+'use strict';
+
+const Utils = require('../utils/Utils');
+const Actions = require('../core/Actions');
+const Constants = require('../utils/Constants');
+const YTDataItems = require('./youtube');
+const YTMusicDataItems = require('./ytmusic');
+
+class Parser {
+ constructor(session, data, args = {}) {
+ this.session = session;
+ this.data = data;
+ this.args = args;
+ }
+
+ parse() {
+ const client = this.args.client;
+ const data_type = this.args.data_type
+
+ let processed_data;
+
+ switch (client) {
+ case 'YOUTUBE':
+ processed_data = ({
+ SEARCH: () => this.#processSearch(),
+ PLAYLIST: () => this.#processPlaylist(),
+ VIDEO_INFO: () => this.#processVideoInfo()
+ })[data_type]()
+ break;
+ case 'YTMUSIC':
+ processed_data = ({
+ SEARCH: () => this.#processMusicSearch(),
+ PLAYLIST: () => this.#processMusicPlaylist()
+ })[data_type]();
+ break;
+ default:
+ throw new Utils.InnertubeError('Invalid client');
+ }
+
+ return processed_data;
+ }
+
+ #processSearch() {
+ const contents = Utils.findNode(this.data, 'contents', 'contents', 5);
+
+ const processed_data = {};
+
+ const parseItems = (contents) => {
+ const content = contents[0].itemSectionRenderer.contents;
+
+ processed_data.query = content[0]?.showingResultsForRenderer?.originalQuery?.simpleText || this.args.query;
+ processed_data.corrected_query = content[0]?.showingResultsForRenderer?.correctedQueryEndpoint?.searchEndpoint?.query || 'N/A';
+ processed_data.estimated_results = parseInt(this.data.estimatedResults);
+
+ processed_data.videos = YTDataItems.VideoResultItem.parse(content);
+
+ processed_data.getContinuation = async () => {
+ const citem = contents.find((item) => item.continuationItemRenderer);
+ const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
+
+ const response = await Actions.search(this.session, 'YOUTUBE', { ctoken });
+ if (!response.success) throw new Utils.InnertubeError('Could not get continuation', response);
+
+ const continuation_items = Utils.findNode(response.data, 'onResponseReceivedCommands', 'itemSectionRenderer', 4, false);
+ return parseItems(continuation_items);
+ };
+
+ return processed_data;
+ }
+
+ return parseItems(contents);
+ }
+
+ #processMusicSearch() {
+ const tabs = Utils.findNode(this.data, 'contents', 'tabs').tabs;
+ const contents = Utils.findNode(tabs, '0', 'contents', 5);
+
+ const did_you_mean_item = contents.find((content) => content.itemSectionRenderer);
+ const did_you_mean_renderer = did_you_mean_item?.itemSectionRenderer.contents[0].didYouMeanRenderer;
+
+ const processed_data = {
+ query: '',
+ corrected_query: '',
+ results: {}
+ };
+
+ processed_data.query = this.args.query;
+ processed_data.corrected_query = did_you_mean_renderer?.correctedQuery.runs.map((run) => run.text).join('') || 'N/A';
+
+ contents.forEach((content) => {
+ const section = content?.musicShelfRenderer;
+ if (section) {
+ const section_title = section.title.runs[0].text;
+
+ const section_items = ({
+ ['Top result']: () => YTMusicDataItems.TopResultItem.parse(section.contents), // console.log(JSON.stringify(section.contents, null, 4)),
+ ['Songs']: () => YTMusicDataItems.SongResultItem.parse(section.contents),
+ ['Videos']: () => YTMusicDataItems.VideoResultItem.parse(section.contents),
+ ['Featured playlists']: () => YTMusicDataItems.PlaylistResultItem.parse(section.contents),
+ ['Community playlists']: () => YTMusicDataItems.PlaylistResultItem.parse(section.contents),
+ ['Artists']: () => YTMusicDataItems.ArtistResultItem.parse(section.contents),
+ ['Albums']: () => YTMusicDataItems.AlbumResultItem.parse(section.contents)
+ })[section_title]();
+
+ processed_data.results[section_title.replace(/ /g, '_').toLowerCase()] = section_items;
+ }
+ });
+
+ return processed_data;
+ }
+
+ #processPlaylist() {
+ const details = this.data.sidebar.playlistSidebarRenderer.items[0];
+
+ const metadata = {
+ title: this.data.metadata.playlistMetadataRenderer.title,
+ description: details.playlistSidebarPrimaryInfoRenderer.description.simpleText || 'N/A',
+ total_items: details.playlistSidebarPrimaryInfoRenderer.stats[0].runs[0].text,
+ last_updated: details.playlistSidebarPrimaryInfoRenderer.stats[2].runs[1].text,
+ views: details.playlistSidebarPrimaryInfoRenderer.stats[1].simpleText
+ }
+
+ const list = Utils.findNode(this.data, 'contents', 'contents', 13, false);
+ const items = YTDataItems.PlaylistItem.parse(list.contents);
+
+ return {
+ ...metadata,
+ items
+ }
+ }
+
+ #processMusicPlaylist() {
+ const details = this.data.header.musicDetailHeaderRenderer;
+
+ const metadata = {
+ title: details?.title?.runs[0].text,
+ description: details?.description?.runs?.map((run) => run.text).join('') || 'N/A',
+ total_items: parseInt(details?.secondSubtitle?.runs[0].text.match(/\d+/g)),
+ duration: details?.secondSubtitle?.runs[2].text,
+ year: details?.subtitle?.runs[4].text
+ };
+
+ const contents = this.data.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents;
+ const playlist_content = contents[0].musicPlaylistShelfRenderer.contents;
+
+ const items = YTMusicDataItems.PlaylistItem.parse(playlist_content);
+
+ return {
+ ...metadata,
+ items
+ }
+ }
+
+ /**
+ * Video data is parsed dynamically, so if youtube decides to add something new we won't have to change anything here.
+ */
+ #processVideoInfo() {
+ const playability_status = this.data.playabilityStatus;
+
+ if (playability_status.status == 'ERROR')
+ throw new Error(`Could not retrieve video details: ${playability_status.status} - ${playability_status.reason}`);
+
+ const details = this.data.videoDetails;
+ const microformat = this.data.microformat.playerMicroformatRenderer;
+ const streaming_data = this.data.streamingData;
+
+ const mf_raw_data = Object.entries(microformat);
+ const dt_raw_data = Object.entries(details);
+
+ const processed_data = {
+ id: '',
+ title: '',
+ description: '',
+ thumbnail: [],
+ metadata: {}
+ };
+
+ // Extracts most of the metadata
+ mf_raw_data.forEach((entry) => {
+ const key = Utils.camelToSnake(entry[0]);
+ if (Constants.METADATA_KEYS.includes(key)) {
+ key == 'view_count' && (processed_data.metadata[key] = parseInt(entry[1])) ||
+ key == 'owner_profile_url' && (processed_data.metadata.channel_url = entry[1]) ||
+ key == 'owner_channel_name' && (processed_data.metadata.channel_name = entry[1]) ||
+ (processed_data.metadata[key] = entry[1]);
+ } else {
+ processed_data[key] = entry[1];
+ }
+ });
+
+ // Extracts extra details
+ dt_raw_data.forEach((entry) => {
+ const key = Utils.camelToSnake(entry[0]);
+ if (Constants.BLACKLISTED_KEYS.includes(key)) return;
+ if (Constants.METADATA_KEYS.includes(key)) {
+ key == 'view_count' && (processed_data.metadata[key] = parseInt(entry[1])) ||
+ (processed_data.metadata[key] = entry[1]);
+ } else {
+ key == 'short_description' && (processed_data.description = entry[1]) ||
+ key == 'thumbnail' && (processed_data.thumbnail = entry[1].thumbnails.slice(-1)[0]) ||
+ key == 'video_id' && (processed_data.id = entry[1]) ||
+ (processed_data[key] = entry[1]);
+ }
+ });
+
+ // Data continuation is only required for getDetails()
+ if (this.data.continuation) {
+ const primary_info_renderer = this.data.continuation.contents.twoColumnWatchNextResults
+ .results.results.contents.find((item) => item.videoPrimaryInfoRenderer).videoPrimaryInfoRenderer;
+
+ const secondary_info_renderer = this.data.continuation.contents.twoColumnWatchNextResults
+ .results.results.contents.find((item) => item.videoSecondaryInfoRenderer).videoSecondaryInfoRenderer;
+
+ const like_btn = primary_info_renderer.videoActions.menuRenderer
+ .topLevelButtons.find((item) => item.toggleButtonRenderer.defaultIcon.iconType == 'LIKE');
+
+ const dislike_btn = primary_info_renderer.videoActions.menuRenderer
+ .topLevelButtons.find((item) => item.toggleButtonRenderer.defaultIcon.iconType == 'DISLIKE');
+
+ const notification_toggle_btn = secondary_info_renderer.subscribeButton.subscribeButtonRenderer
+ ?.notificationPreferenceButton?.subscriptionNotificationToggleButtonRenderer;
+
+ // These will always be false if logged out.
+ processed_data.metadata.is_liked = like_btn.toggleButtonRenderer.isToggled;
+ processed_data.metadata.is_disliked = dislike_btn.toggleButtonRenderer.isToggled;
+ processed_data.metadata.is_subscribed = secondary_info_renderer.subscribeButton.subscribeButtonRenderer?.subscribed || false;
+
+ processed_data.metadata.subscriber_count = secondary_info_renderer.owner.videoOwnerRenderer?.subscriberCountText?.simpleText || 'N/A';
+ processed_data.metadata.current_notification_preference = notification_toggle_btn?.states.find((state) => state.stateId == notification_toggle_btn.currentStateId)
+ .state.buttonRenderer.icon.iconType || 'N/A';
+
+ // Simpler version of publish_date
+ processed_data.metadata.publish_date_text = primary_info_renderer.dateText.simpleText;
+
+ // Only parse like count if it's enabled
+ if (processed_data.metadata.allow_ratings) {
+ processed_data.metadata.likes = {
+ count: parseInt(like_btn.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, '')),
+ short_count_text: like_btn.toggleButtonRenderer.defaultText.simpleText
+ };
+ }
+
+ processed_data.metadata.owner_badges = secondary_info_renderer.owner.videoOwnerRenderer?.badges?.map((badge) => badge.metadataBadgeRenderer.tooltip) || [];
+ }
+
+ streaming_data && streaming_data.adaptiveFormats &&
+ (processed_data.metadata.available_qualities = [...new Set(streaming_data.adaptiveFormats.filter(v => v.qualityLabel)
+ .map(v => v.qualityLabel).sort((a, b) => +a.replace(/\D/gi, '') - +b.replace(/\D/gi, '')))]) ||
+ (processed_data.metadata.available_qualities = []);
+
+ return processed_data;
+ }
+}
+
+module.exports = Parser;
diff --git a/lib/parser/youtube/index.js b/lib/parser/youtube/index.js
new file mode 100644
index 00000000..1fe741bb
--- /dev/null
+++ b/lib/parser/youtube/index.js
@@ -0,0 +1,6 @@
+'use strict';
+
+const VideoResultItem = require('./search/VideoResultItem');
+const PlaylistItem = require('./others/PlaylistItem');
+
+module.exports = { VideoResultItem, PlaylistItem };
\ No newline at end of file
diff --git a/lib/parser/youtube/others/PlaylistItem.js b/lib/parser/youtube/others/PlaylistItem.js
new file mode 100644
index 00000000..daa8aacb
--- /dev/null
+++ b/lib/parser/youtube/others/PlaylistItem.js
@@ -0,0 +1,26 @@
+'use strict';
+
+const Utils = require('../../../utils/Utils');
+
+class PlaylistItem {
+ static parse(data) {
+ return data.map((item) => this.parseItem(item)).filter((item) => item);
+ }
+
+ static parseItem(item) {
+ if (item.playlistVideoRenderer)
+ return {
+ id: item?.playlistVideoRenderer?.videoId,
+ title: item?.playlistVideoRenderer?.title?.runs[0]?.text,
+ author: item?.playlistVideoRenderer?.shortBylineText?.runs[0]?.text,
+ duration: {
+ seconds: Utils.timeToSeconds(item?.playlistVideoRenderer?.lengthText?.simpleText || '0'),
+ simple_text: item?.playlistVideoRenderer?.lengthText?.simpleText || 'N/A',
+ accessibility_label: item?.playlistVideoRenderer?.lengthText?.accessibility?.accessibilityData?.label || 'N/A'
+ },
+ thumbnails: item?.playlistVideoRenderer?.thumbnail?.thumbnails,
+ };
+ }
+}
+
+module.exports = PlaylistItem;
\ No newline at end of file
diff --git a/lib/parser/youtube/search/VideoResultItem.js b/lib/parser/youtube/search/VideoResultItem.js
new file mode 100644
index 00000000..2643dfd4
--- /dev/null
+++ b/lib/parser/youtube/search/VideoResultItem.js
@@ -0,0 +1,43 @@
+'use strict';
+
+const Utils = require('../../../utils/Utils');
+const Constants = require('../../../utils/Constants');
+
+class VideoResultItem {
+ static parse(data) {
+ return data.map((item) => this.parseItem(item)).filter((item) => item);
+ }
+
+ static parseItem(item) {
+ const renderer = item.videoRenderer || item.compactVideoRenderer;
+ if (renderer) return {
+ id: renderer.videoId,
+ url: `https://youtu.be/${renderer.videoId}`,
+ title: renderer.title.runs[0].text,
+ description: renderer?.detailedMetadataSnippets && renderer?.detailedMetadataSnippets[0].snippetText.runs.map((item) => item.text).join('') || 'N/A',
+ channel: {
+ id: renderer?.ownerText?.runs[0]?.navigationEndpoint?.browseEndpoint?.browseId,
+ name: renderer?.ownerText?.runs[0]?.text,
+ url: `${Constants.URLS.YT_BASE}${renderer.ownerText.runs[0].navigationEndpoint?.browseEndpoint?.canonicalBaseUrl}`
+ },
+ metadata: {
+ view_count: renderer?.viewCountText?.simpleText || 'N/A',
+ short_view_count_text: {
+ simple_text: renderer?.shortViewCountText?.simpleText || 'N/A',
+ accessibility_label: renderer?.shortViewCountText?.accessibility?.accessibilityData?.label || 'N/A',
+ },
+ thumbnails: renderer?.thumbnail.thumbnails,
+ duration: {
+ seconds: Utils.timeToSeconds(renderer?.lengthText?.simpleText || '0'),
+ simple_text: renderer?.lengthText?.simpleText || 'N/A',
+ accessibility_label: renderer?.lengthText?.accessibility?.accessibilityData?.label || 'N/A'
+ },
+ published: renderer?.publishedTimeText?.simpleText || 'N/A',
+ badges: renderer?.badges?.map((item) => item.metadataBadgeRenderer.label) || [],
+ owner_badges: renderer?.ownerBadges?.map((item) => item.metadataBadgeRenderer.tooltip) || []
+ }
+ };
+ }
+}
+
+module.exports = VideoResultItem;
diff --git a/lib/parser/ytmusic/index.js b/lib/parser/ytmusic/index.js
new file mode 100644
index 00000000..ef919335
--- /dev/null
+++ b/lib/parser/ytmusic/index.js
@@ -0,0 +1,11 @@
+'use strict';
+
+const SongResultItem = require('./search/SongResultItem');
+const VideoResultItem = require('./search/VideoResultItem');
+const AlbumResultItem = require('./search/AlbumResultItem');
+const ArtistResultItem = require('./search/ArtistResultItem');
+const PlaylistResultItem = require('./search/PlaylistResultItem');
+const TopResultItem = require('./search/TopResultItem');
+const PlaylistItem = require('./others/PlaylistItem');
+
+module.exports = { SongResultItem, VideoResultItem, AlbumResultItem, ArtistResultItem, PlaylistResultItem, TopResultItem, PlaylistItem };
\ No newline at end of file
diff --git a/lib/parser/ytmusic/others/PlaylistItem.js b/lib/parser/ytmusic/others/PlaylistItem.js
new file mode 100644
index 00000000..16c35f98
--- /dev/null
+++ b/lib/parser/ytmusic/others/PlaylistItem.js
@@ -0,0 +1,28 @@
+'use strict';
+
+const Utils = require('../../../utils/Utils');
+
+class PlaylistItem {
+ static parse(data) {
+ return data.map((item) => this.parseItem(item)).filter((item) => item.id);
+ }
+
+ static parseItem(item) {
+ const item_renderer = item.musicResponsiveListItemRenderer;
+ const fixed_columns = item_renderer.fixedColumns;
+ const flex_columns = item_renderer.flexColumns;
+
+ return {
+ id: item_renderer.playlistItemData && item_renderer.playlistItemData.videoId,
+ title: flex_columns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
+ author: flex_columns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
+ duration: {
+ seconds: Utils.timeToSeconds(fixed_columns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text || '0'),
+ simple_text: fixed_columns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text,
+ },
+ thumbnails: item_renderer.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails
+ }
+ }
+}
+
+module.exports = PlaylistItem;
\ No newline at end of file
diff --git a/lib/parser/ytmusic/search/AlbumResultItem.js b/lib/parser/ytmusic/search/AlbumResultItem.js
new file mode 100644
index 00000000..532f8298
--- /dev/null
+++ b/lib/parser/ytmusic/search/AlbumResultItem.js
@@ -0,0 +1,21 @@
+'use strict';
+
+class AlbumResultItem {
+ static parse(data) {
+ return data.map((item) => this.parseItem(item));
+ }
+
+ static parseItem(item) {
+ const list_item = item.musicResponsiveListItemRenderer;
+ return {
+ id: list_item.navigationEndpoint.browseEndpoint.browseId,
+ title: list_item.flexColumns[0]?.musicResponsiveListItemFlexColumnRenderer.text.runs[0]?.text,
+ author: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.text,
+ year: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs
+ .find((run) => /^[12][0-9]{3}$/.test(run.text)).text,
+ thumbnails: list_item?.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails,
+ };
+ }
+}
+
+module.exports = AlbumResultItem;
\ No newline at end of file
diff --git a/lib/parser/ytmusic/search/ArtistResultItem.js b/lib/parser/ytmusic/search/ArtistResultItem.js
new file mode 100644
index 00000000..2f7057cc
--- /dev/null
+++ b/lib/parser/ytmusic/search/ArtistResultItem.js
@@ -0,0 +1,19 @@
+'use strict';
+
+class ArtistResultItem {
+ static parse(data) {
+ return data.map((item) => this.parseItem(item));
+ }
+
+ static parseItem(item) {
+ const list_item = item.musicResponsiveListItemRenderer;
+ return {
+ id: list_item.navigationEndpoint.browseEndpoint.browseId,
+ name: list_item.flexColumns[0]?.musicResponsiveListItemFlexColumnRenderer.text.runs[0]?.text,
+ subscribers: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.text,
+ thumbnails: list_item?.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails,
+ };
+ }
+}
+
+module.exports = ArtistResultItem;
\ No newline at end of file
diff --git a/lib/parser/ytmusic/search/PlaylistResultItem.js b/lib/parser/ytmusic/search/PlaylistResultItem.js
new file mode 100644
index 00000000..88fde6fe
--- /dev/null
+++ b/lib/parser/ytmusic/search/PlaylistResultItem.js
@@ -0,0 +1,23 @@
+'use strict';
+
+class PlaylistResultItem {
+ static parse(data) {
+ return data.map((item) => this.parseItem(item));
+ }
+
+ static parseItem(item) {
+ const list_item = item.musicResponsiveListItemRenderer;
+ const watch_playlist_endpoint = list_item.overlay.musicItemThumbnailOverlayRenderer
+ .content.musicPlayButtonRenderer.playNavigationEndpoint.watchPlaylistEndpoint;
+
+ return {
+ id: watch_playlist_endpoint?.playlistId,
+ title: list_item.flexColumns[0]?.musicResponsiveListItemFlexColumnRenderer.text.runs[0]?.text,
+ author: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.text,
+ channel_id: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.navigationEndpoint?.browseEndpoint.browseId || '0',
+ total_items: parseInt(list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[4]?.text.match(/\d+/g)),
+ };
+ }
+}
+
+module.exports = PlaylistResultItem;
\ No newline at end of file
diff --git a/lib/parser/ytmusic/search/SongResultItem.js b/lib/parser/ytmusic/search/SongResultItem.js
new file mode 100644
index 00000000..0c9cd88e
--- /dev/null
+++ b/lib/parser/ytmusic/search/SongResultItem.js
@@ -0,0 +1,22 @@
+'use strict';
+
+class SongResultItem {
+ static parse(data) {
+ return data.map((item) => this.parseItem(item)).filter((item) => item);
+ }
+
+ static parseItem(item) {
+ const list_item = item.musicResponsiveListItemRenderer;
+ if (list_item.playlistItemData) return {
+ id: list_item.playlistItemData.videoId,
+ title: list_item.flexColumns[0]?.musicResponsiveListItemFlexColumnRenderer.text.runs[0]?.text,
+ artist: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.text,
+ album: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[4]?.text,
+ duration: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs
+ .find((run) => /^\d+$/.test(run.text.replace(/:/g, ''))).text,
+ thumbnails: list_item.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails,
+ };
+ }
+}
+
+module.exports = SongResultItem;
\ No newline at end of file
diff --git a/lib/parser/ytmusic/search/TopResultItem.js b/lib/parser/ytmusic/search/TopResultItem.js
new file mode 100644
index 00000000..7286b00b
--- /dev/null
+++ b/lib/parser/ytmusic/search/TopResultItem.js
@@ -0,0 +1,32 @@
+'use strict';
+
+const SongResultItem = require('./SongResultItem');
+const VideoResultItem = require('./VideoResultItem');
+const AlbumResultItem = require('./AlbumResultItem');
+const ArtistResultItem = require('./ArtistResultItem');
+const PlaylistResultItem = require('./PlaylistResultItem');
+
+class TopResultItem {
+ static parse(data) {
+ return data.map((item) => {
+ const list_item = item.musicResponsiveListItemRenderer;
+
+ const runs = list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs;
+ const type = runs[0].text.toLowerCase();
+
+ const parsed_item = ({
+ playlist: () => PlaylistResultItem.parseItem(item),
+ song: () => SongResultItem.parseItem(item),
+ video: () => VideoResultItem.parseItem(item),
+ artist: () => ArtistResultItem.parseItem(item),
+ album: () => ArtistResultItem.parseItem(item)
+ }[type])();
+
+ parsed_item.type = type;
+
+ return parsed_item;
+ }).filter((item) => item);
+ }
+}
+
+module.exports = TopResultItem;
\ No newline at end of file
diff --git a/lib/parser/ytmusic/search/VideoResultItem.js b/lib/parser/ytmusic/search/VideoResultItem.js
new file mode 100644
index 00000000..44842d24
--- /dev/null
+++ b/lib/parser/ytmusic/search/VideoResultItem.js
@@ -0,0 +1,22 @@
+'use strict';
+
+class VideoResultItem {
+ static parse(data) {
+ return data.map((item) => this.parseItem(item)).filter((item) => item);
+ }
+
+ static parseItem(item) {
+ const list_item = item.musicResponsiveListItemRenderer;
+ if (list_item.playlistItemData) return {
+ id: list_item.playlistItemData.videoId,
+ title: list_item.flexColumns[0]?.musicResponsiveListItemFlexColumnRenderer.text.runs[0]?.text,
+ author: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.text,
+ views: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[4]?.text,
+ duration: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs
+ .find((run) => /^\d+$/.test(run.text.replace(/:/g, ''))).text,
+ thumbnails: list_item?.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails,
+ };
+ }
+}
+
+module.exports = VideoResultItem;
\ No newline at end of file
diff --git a/lib/Constants.js b/lib/utils/Constants.js
similarity index 98%
rename from lib/Constants.js
rename to lib/utils/Constants.js
index f02617d7..d344583a 100644
--- a/lib/Constants.js
+++ b/lib/utils/Constants.js
@@ -61,13 +61,13 @@ module.exports = {
'x-origin': origin,
'origin': origin
};
-
+
if (info.session.logged_in) {
-
+
headers.Cookie = info.session.cookie;
headers.authorization = info.session.cookie.length && info.session.auth_apisid || `Bearer ${info.session.access_token}`;
}
-
+
return headers
},
VIDEO_INFO_REQBODY: (id, sts, context) => {
@@ -120,6 +120,10 @@ module.exports = {
NORMAL: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'.split(''),
REVERSE: '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'.split('')
},
+ NTOKEN_REGEX: {
+ CALLS: /c\[(.*?)\]\((.+?)\)/g,
+ PLACEHOLDERS: /c\[(.*?)\]=c/g,
+ },
FUNCS_REGEX: /d\.push\(e\)|d\.reverse\(\)|d\[0\]\)\[0\]\)|f=d\[0];d\[0\]|d\.length;d\.splice\(e,1\)|function\(\){for\(var|function\(d,e,f\){var|function\(d\){for\(var|reverse\(\)\.forEach|unshift\(d\.pop\(\)\)|function\(d,e\){for\(var f/,
FUNCS: {
PUSH: 'd.push(e)',
diff --git a/lib/Utils.js b/lib/utils/Utils.js
similarity index 94%
rename from lib/Utils.js
rename to lib/utils/Utils.js
index d8c55153..311fc5a7 100644
--- a/lib/Utils.js
+++ b/lib/utils/Utils.js
@@ -6,11 +6,11 @@ const UserAgent = require('user-agents');
const Flatten = require('flat');
function InnertubeError(message, info) {
- this.info = info;
- this.stack = Error(message).stack;
-}
+ this.info = info;
+ this.stack = Error(message).stack;
+}
-InnertubeError.prototype = Object.create(Error.prototype);
+InnertubeError.prototype = Object.create(Error.prototype);
InnertubeError.prototype.constructor = InnertubeError;
class ParsingError extends InnertubeError {};
@@ -28,7 +28,7 @@ class NoStreamingDataError extends InnertubeError {};
* @param {number} depth - Maximum number of nested objects to flatten.
* @param {boolean} safe - If set to true arrays will be preserved.
*/
-function findNode (obj, key, target, depth, safe = true) {
+function findNode(obj, key, target, depth, safe = true) {
const flat_obj = Flatten(obj, { safe, maxDepth: depth || 2 });
const result = Object.keys(flat_obj).find((entry) => entry.includes(key) && JSON.stringify(flat_obj[entry] || '{}').includes(target));
if (!result) throw new ParsingError(`Expected to find "${key}" with content "${target}" but got ${result}`, { key, target, data_snippet: `${JSON.stringify(flat_obj).slice(0, 300)}..` });
diff --git a/package-lock.json b/package-lock.json
index dda028f3..5830a68f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "youtubei.js",
- "version": "1.3.8",
+ "version": "1.4.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "youtubei.js",
- "version": "1.3.8",
+ "version": "1.4.0",
"license": "MIT",
"dependencies": {
"axios": "^0.21.4",
@@ -15,6 +15,9 @@
"user-agents": "^1.0.778",
"uuid": "^8.3.2"
},
+ "engines": {
+ "node": ">=14"
+ },
"funding": {
"url": "https://ko-fi.com/luanrt"
}
@@ -139,9 +142,9 @@
}
},
"node_modules/user-agents": {
- "version": "1.0.971",
- "resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.971.tgz",
- "integrity": "sha512-5gwiuDE6Rmi4YRf1wDedduBwkv1RwdveW295+qMbdnWhc6CFSeVceQ2rpYxP/m022E0f45Z7ednPjerh9SMuXw==",
+ "version": "1.0.984",
+ "resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.984.tgz",
+ "integrity": "sha512-gFYUg9GRrUA5LPKBa+K2K6jML3VPseVxm2TzhfTMVpLuxYZGm4qM8egSfQ7DV8X4DTNwECHAhlwv6JWZnIsCHQ==",
"dependencies": {
"dot-json": "^1.2.2",
"lodash.clonedeep": "^4.5.0"
@@ -256,9 +259,9 @@
}
},
"user-agents": {
- "version": "1.0.971",
- "resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.971.tgz",
- "integrity": "sha512-5gwiuDE6Rmi4YRf1wDedduBwkv1RwdveW295+qMbdnWhc6CFSeVceQ2rpYxP/m022E0f45Z7ednPjerh9SMuXw==",
+ "version": "1.0.984",
+ "resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.984.tgz",
+ "integrity": "sha512-gFYUg9GRrUA5LPKBa+K2K6jML3VPseVxm2TzhfTMVpLuxYZGm4qM8egSfQ7DV8X4DTNwECHAhlwv6JWZnIsCHQ==",
"requires": {
"dot-json": "^1.2.2",
"lodash.clonedeep": "^4.5.0"
diff --git a/package.json b/package.json
index 61124b20..2d98bac0 100644
--- a/package.json
+++ b/package.json
@@ -1,14 +1,17 @@
{
"name": "youtubei.js",
- "version": "1.3.8",
- "description": "An object-oriented library that allows you to search, get detailed info about videos, subscribe, unsubscribe, like, dislike, comment, download videos and much more!",
+ "version": "1.4.0",
+ "description": "A full-featured library that allows you to get detailed info about any video, subscribe, unsubscribe, like, dislike, comment, search, download videos/music and much more!",
"main": "index.js",
+ "author": "LuanRT (https://github.com/LuanRT)",
+ "funding": "https://ko-fi.com/luanrt",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
"scripts": {
"test": "node test"
},
- "author": "LuanRT",
- "funding": "https://ko-fi.com/luanrt",
- "license": "MIT",
"directories": {
"example": "examples",
"lib": "lib"
@@ -24,6 +27,10 @@
"type": "git",
"url": "git+https//github.com/LuanRT/YouTube.js.git"
},
+ "bugs": {
+ "url": "https://github.com/LuanRT/YouTube.js/issues"
+ },
+ "homepage": "https://github.com/LuanRT/YouTube.js#readme",
"keywords": [
"yt",
"ytdl",
@@ -33,17 +40,13 @@
"youtube-downloader",
"innertube",
"innertubeapi",
- "livechat",
"downloader",
+ "livechat",
"dislike",
"search",
"comment",
"like",
"api",
"dl"
- ],
- "bugs": {
- "url": "https://github.com/LuanRT/YouTube.js/issues"
- },
- "homepage": "https://github.com/LuanRT/YouTube.js#readme"
+ ]
}
diff --git a/test/index.js b/test/index.js
index 1b3247fc..689b236c 100644
--- a/test/index.js
+++ b/test/index.js
@@ -1,61 +1,75 @@
-'use strict';
-
-const Fs = require('fs');
-const Innertube = require('..');
-const NToken = require('../lib/NToken');
-const SigDecipher = require('../lib/Sig');
-const Constants = require('./constants');
-
-let failed_tests = 0;
-
-async function performTests() {
- const youtube = await new Innertube().catch((error) => error);
- assert(!(youtube instanceof Error), `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, { sig_decipher_sc: Constants.sig_decipher_sc }).decipher();
- assert(transformed_url == Constants.expected_url, `should correctly decipher signature`, transformed_url);
-
- if (failed_tests > 0)
- throw new Error('Some tests have failed');
-}
-
-function downloadVideo(id, youtube) {
- return new Promise((resolve, reject) => {
- let got_video_info = false;
- const stream = youtube.download(id, { type: 'videoandaudio' });
- stream.pipe(Fs.createWriteStream(`./${id}.mp4`));
- stream.on('end', () => Fs.existsSync(`./${id}.mp4`) && got_video_info && resolve() || reject());
- stream.on('info', () => got_video_info = true);
- stream.on('error', (err) => reject(err));
- });
-}
-
-function assert(outcome, description, data) {
- const pass_fail = outcome ? 'pass' : 'fail';
-
- console.info(pass_fail, ':', description);
- !outcome && (failed_tests += 1);
- !outcome && console.error('Error: ', data);
-
- return outcome;
-}
-
-performTests();
+'use strict';
+
+const Fs = require('fs');
+const Innertube = require('..');
+const NToken = require('../lib/deciphers/NToken');
+const SigDecipher = require('../lib/deciphers/Sig');
+const Constants = require('./constants');
+
+let failed_tests = 0;
+
+async function performTests() {
+ const youtube = await new Innertube().catch((error) => error);
+ assert(!(youtube instanceof Error), `should retrieve Innertube configuration data`, youtube);
+
+ if (!(youtube instanceof Error)) {
+ const homefeed = await youtube.getHomeFeed();
+ assert(!(homefeed instanceof Error), `should retrieve recommendations`, homefeed);
+
+ const ytsearch = await youtube.search('Carl Sagan - Documentary').catch((error) => error);
+ assert(!(ytsearch instanceof Error) && ytsearch.videos.length, `should search on YouTube`, ytsearch);
+
+ const ytmsearch = await youtube.search('Logic - Obediently Yours', { client: 'YTMUSIC' }).catch((error) => error);
+ assert(!(ytmsearch instanceof Error), `should search on YouTube Music`, ytmsearch);
+
+ const details = await youtube.getDetails(Constants.test_video_id).catch((error) => error);
+ assert(!(details instanceof Error), `should retrieve details for ${Constants.test_video_id}`, details);
+
+ const comments = await youtube.getComments(Constants.test_video_id).catch((error) => error);
+ assert(!(comments instanceof Error), `should retrieve comments for ${Constants.test_video_id}`, comments);
+
+ const ytplaylist = await youtube.getPlaylist(ytmsearch.results.community_playlists[0].id, { client: 'YOUTUBE' });
+ assert(!(ytplaylist instanceof Error), `should retrieve and parse playlist with YouTube`, ytplaylist);
+
+ const ytmplaylist = await youtube.getPlaylist(ytmsearch.results.community_playlists[0].id, { client: 'YTMUSIC' });
+ assert(!(ytmplaylist instanceof Error), `should retrieve and parse playlist with YouTube Music`, ytmplaylist);
+
+ const lyrics = await youtube.getLyrics(ytmsearch.results.songs[0].id);
+ assert(!(lyrics instanceof Error), `should retrieve song lyrics`, lyrics);
+
+ const video = await downloadVideo(Constants.test_video_id, youtube).catch((error) => error);
+ assert(!(video instanceof Error), `should download video (${Constants.test_video_id})`, video);
+ }
+
+ const n_token = new NToken(Constants.n_scramble_sc, Constants.original_ntoken).transform();
+ assert(n_token == Constants.expected_ntoken, `should transform n token into ${Constants.expected_ntoken}`, n_token);
+
+ const transformed_url = new SigDecipher(Constants.test_url, { sig_decipher_sc: Constants.sig_decipher_sc }).decipher();
+ assert(transformed_url == Constants.expected_url, `should correctly decipher signature`, transformed_url);
+
+ if (failed_tests > 0)
+ throw new Error('Some tests have failed');
+}
+
+function downloadVideo(id, youtube) {
+ return new Promise((resolve, reject) => {
+ let got_video_info = false;
+ const stream = youtube.download(id, { type: 'videoandaudio' });
+ stream.pipe(Fs.createWriteStream(`./${id}.mp4`));
+ stream.on('end', () => Fs.existsSync(`./${id}.mp4`) && got_video_info && resolve() || reject());
+ stream.on('info', () => got_video_info = true);
+ stream.on('error', (err) => reject(err));
+ });
+}
+
+function assert(outcome, description, data) {
+ const pass_fail = outcome ? 'pass' : 'fail';
+
+ console.info(pass_fail, ':', description);
+ !outcome && (failed_tests += 1);
+ !outcome && console.error('Error: ', data);
+
+ return outcome;
+}
+
+performTests();