Compare commits

..

28 Commits

Author SHA1 Message Date
luan.lrt4@gmail.com
db41fa40d2 chore: bump version to 1.4.2 2022-04-22 00:53:05 -03:00
luan.lrt4@gmail.com
02ece1ddda chore: fix typo 2022-04-22 00:32:43 -03:00
luan.lrt4@gmail.com
b175e02f6d chore: oops 2022-04-22 00:27:03 -03:00
luan.lrt4@gmail.com
d3394f846a feat: add support for reporting comments and add comments sorting option 2022-04-22 00:22:50 -03:00
luan.lrt4@gmail.com
07b73ab78d chore: remove unneeded code 2022-04-20 06:19:36 -03:00
luan.lrt4@gmail.com
d743b5a088 refactor: use a single axios instance and remove redundant code 2022-04-20 06:18:07 -03:00
luan.lrt4@gmail.com
bb206c044c chore(tests): update signature decipher path 2022-04-20 03:55:14 -03:00
luan.lrt4@gmail.com
d48065405d chore: use compiled protobuf schemas to reduce dependency footprint 2022-04-20 03:52:44 -03:00
luan.lrt4@gmail.com
dbc8b62ba2 feat: add option to change geolocation & fix minor bugs, closes #34 2022-04-19 05:35:11 -03:00
luan.lrt4@gmail.com
e32981728b chore(release): add support for trending content and release v1.4.1 2022-04-17 22:27:42 -03:00
luan.lrt4@gmail.com
7b33dcbb79 chore: fix typo 2022-04-16 23:04:47 -03:00
LuanRT
4c6bf49bbe chore(docs): add signOut() example 2022-04-16 22:49:07 -03:00
luan.lrt4@gmail.com
4bbc2d50f4 refactor!: move everything that needs parsing to parser and improve oauth system 2022-04-16 22:08:01 -03:00
luan.lrt4@gmail.com
440d80063d chore: update typings 2022-04-16 22:02:17 -03:00
luan.lrt4@gmail.com
c49147523a chore: update tests 2022-04-16 21:20:21 -03:00
luan.lrt4@gmail.com
e221c79448 chore: move type definitions to its own folder 2022-04-15 14:43:56 -03:00
LuanRT
291d04e703 chore: add type definitions (WIP) 2022-04-15 13:52:25 -03:00
luan.lrt4@gmail.com
12baec0b0d feat: method to bulk add videos to a playlist 2022-04-15 05:59:44 -03:00
luan.lrt4@gmail.com
b793c61fd8 chore: oops 2022-04-15 05:28:12 -03:00
luan.lrt4@gmail.com
b9e15b5fbd feat: add support for playlist creation/deletion 2022-04-15 05:25:52 -03:00
luan.lrt4@gmail.com
d0c54f2b8b chore(docs): remove whitespace 2022-04-15 05:21:49 -03:00
Vorticalbox
6ff984df66 remove: unneeded comment
removed left over comment i added when writing this
2022-04-15 08:54:55 +01:00
Vorticalbox
4fa2e5c127 Create index.d.ts 2022-04-15 08:49:07 +01:00
luan.lrt4@gmail.com
725f186bd9 chore: add YouTube Studio api url (WIP) 2022-04-15 01:00:09 -03:00
luan.lrt4@gmail.com
07340931a0 chore(tests): use results from ytmusic 2022-04-13 18:56:19 -03:00
luan.lrt4@gmail.com
46d62bf83f chore: add more tests for better coverage 2022-04-13 18:50:23 -03:00
luan.lrt4@gmail.com
c28da62ec1 fix: ytmusic search suggestions not working, closes #20 2022-04-13 18:30:52 -03:00
luan.lrt4@gmail.com
c7fc18b516 feat (ytmusic): add support for singles in top result 2022-04-13 18:07:28 -03:00
30 changed files with 3495 additions and 1130 deletions

151
README.md
View File

@@ -66,23 +66,18 @@ And big thanks to [@gatecrasher777](https://github.com/gatecrasher777/ytcog) for
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
- 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
- Search videos, playlists, music, albums etc.
- Subscribe/Unsubscribe/Like/Dislike/Comment etc.
- Get subscriptions/home feed, notifications and watch history.
- Easily sign in to any Google Account.
- Fetch live chat & live stats.
- Manage account settings.
- Create/delete playlists.
- 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.
~ And more!
### Do I need an API key to use this?
No, YouTube.js does not use any official API so no API keys are required.
Do note that you must be signed-in to perform actions that involve an account; such as commenting, liking/disliking videos, sending messages to a live chat, etc.
<!-- GETTING STARTED -->
## Getting Started
@@ -114,7 +109,7 @@ And to make things faster, you should do this only once and reuse the Innertube
```js
const Innertube = require('youtubei.js');
const youtube = await new Innertube();
const youtube = await new Innertube({ gl: 'US' }); // all parameters are optional.
```
### Doing a simple search
@@ -180,7 +175,7 @@ const search = await youtube.search('Interstellar Main Theme', { client: 'YTMUSI
corrected_query:string,
results:{
top_result:[Array], // Can be anything; video, playlist, artist etc..
songs:[
songs:[
{
id:string,
title:string,
@@ -337,13 +332,14 @@ const video = await youtube.getDetails('VIDEO_ID');
### Get comments:
```js
const response = await youtube.getComments('VIDEO_ID');
// Sorting options: `TOP_COMMENTS` and `NEWEST_FIRST`
const comments = await youtube.getComments('VIDEO_ID', 'TOP_COMMENTS');
```
Alternatively you can use:
```js
const video = await youtube.getDetails('VIDEO_ID');
const response = await video.getComments();
const comments = await video.getComments();
```
<details>
<summary>Output</summary>
@@ -351,12 +347,14 @@ const response = await video.getComments();
```js
{
comments: [
page_count: number,
comment_count: number,
items: [
{
text: string,
author: {
name: string,
thumbnail: [
thumbnails: [
{
url: string,
width: number,
@@ -371,35 +369,36 @@ const response = await video.getComments();
is_disliked: boolean,
is_pinned: boolean,
is_channel_owner: boolean,
is_reply: boolean,
like_count: number,
reply_count: number,
id: string
}
},
//...
],
comment_count: string // not available in continuations
]
}
```
</p>
</details>
Reply to, like and dislike comments:
Reply to, like/dislike and report a comment:
```js
await response.comments[0].like();
await response.comments[0].dislike();
await response.comments[0].reply('Nice comment!');
await comments.items[0].like();
await comments.items[0].dislike();
await comments.items[0].report();
await comments.items[0].reply('Nice comment!');
```
Get comment replies:
Comment replies:
```js
const replies = await response.comments[0].getReplies();
const replies = await comments.items[0].getReplies();
```
Get comments/replies continuation:
Comments/replies continuation:
```js
const continuation = await response.getContinuation();
const continuation = await comments.getContinuation();
const replies_continuation = await replies.getContinuation();
```
@@ -454,7 +453,7 @@ const homefeed = await youtube.getHomeFeed();
</p>
</details>
Get continuation:
Continuation:
```js
const continuation = await homefeed.getContinuation();
````
@@ -515,7 +514,7 @@ const history = await youtube.getHistory();
</p>
</details>
Get continuation:
Continuation:
```js
const continuation = await history.getContinuation();
````
@@ -576,11 +575,48 @@ const mysubsfeed = await youtube.getSubscriptionsFeed();
</p>
</details>
Get continuation:
Continuation:
```js
const continuation = await mysubsfeed.getContinuation();
````
### Get trending content:
```js
const trending = await youtube.getTrending();
```
<details>
<summary>Output</summary>
<p>
```js
{
now: {
content: [
{
title: string,
videos: []
},
//...
]
},
// Other categories require an additional call to fetch videos
music: { getVideos: Promise.<Array> },
gaming: { getVideos: Promise.<Array> },
movies: { getVideos: Promise.<Array> }
}
```
</p>
</details>
### 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 notifications:
```js
@@ -620,21 +656,15 @@ const notifications = await youtube.getNotifications();
</p>
</details>
Get continuation:
Continuation:
```js
const continuation = await notifications.getContinuation();
````
### Get unseen notifications count:
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);
const unread_notis_count = await youtube.getUnseenNotificationsCount();
```
### Get playlist:
@@ -732,12 +762,24 @@ The library makes it easy to interact with YouTube programmatically. However, do
```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');
```
* Playlists:
```js
// Create a playlist:
await youtube.playlist.create('NAME', 'VIDEO_ID');
// Delete a playlist:
await youtube.playlist.delete('PLAYLIST_ID');
// Add videos to a playlist:
await youtube.playlist.addVideos('PLAYLIST_ID', [ 'VIDEO_ID1', 'VIDEO_ID2' ]);
```
* Change notification preferences:
```js
// Options: ALL | NONE | PERSONALIZED
await youtube.interact.setNotificationPreferences('CHANNEL_ID', 'ALL');
```
These methods will always return ```{ success: true, status_code: 200 }``` if successful.
@@ -1025,6 +1067,13 @@ async function start() {
start();
```
Sign-out:
```js
const response = await youtube.signOut();
if (response.success) {
console.log('You have successfully signed out');
}
```
#### Cookies:
@@ -1032,7 +1081,7 @@ start();
const Innertube = require('youtubei.js');
async function start() {
const youtube = await new Innertube(COOKIE_HERE);
const youtube = await new Innertube({ cookie: '...' });
//...
}
@@ -1064,4 +1113,4 @@ Should you have any questions or concerns please contact me directly via email.
## License
Distributed under the [MIT](https://choosealicense.com/licenses/mit/) License.
<p align="right">(<a href="#top">back to top</a>)</p>
<p align="right">(<a href="#top">back to top</a>)</p>

File diff suppressed because it is too large Load Diff

View File

@@ -3,18 +3,19 @@
const Uuid = require('uuid');
const Axios = require('axios');
const Proto = require('../proto');
const Utils = require('../utils/Utils');
const Constants = require('../utils/Constants');
/**
* Performs direct interactions on YouTube.
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} engagement_type - Type of engagement.
* @param {object} args - Engagement arguments.
* @param {Innertube} session
* @param {string} engagement_type
* @param {object} args
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
*/
async function engage(session, engagement_type, args = {}) {
if (!session.logged_in) throw new Error('You are not signed in');
if (!session.logged_in) throw new Utils.InnertubeError('You are not signed in');
const data = { context: session.context };
switch (engagement_type) {
@@ -40,16 +41,30 @@ async function engage(session, engagement_type, args = {}) {
break;
case 'comment/perform_comment_action':
const action = ({
like: () => Proto.encodeCommentActionParams(5, args.comment_id, args.video_id, args.channel_id),
dislike: () => Proto.encodeCommentActionParams(4, args.comment_id, args.video_id, args.channel_id),
like: () => Proto.encodeCommentActionParams(5, args.comment_id, args.video_id),
dislike: () => Proto.encodeCommentActionParams(4, args.comment_id, args.video_id),
})[args.comment_action]();
data.actions = [action];
data.actions = [ action ];
break;
case 'playlist/create':
data.title = args.title;
data.videoIds = [args.video_id];
break;
case 'playlist/delete':
data.playlistId = args.playlist_id;
break;
case 'browse/edit_playlist':
data.playlistId = args.playlist_id;
data.actions = args.video_ids.map((id) => ({
action: args.action,
addedVideoId: id
}));
break;
default:
throw new Utils.InnertubeError('Invalid action', action);
throw new Utils.InnertubeError('Invalid action', engagement_type);
}
const response = await session.YTRequester.post(`/${engagement_type}`, JSON.stringify(data)).catch((error) => error);
const response = await session.request.post(`/${engagement_type}`, JSON.stringify(data)).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
return {
@@ -61,16 +76,15 @@ async function engage(session, engagement_type, args = {}) {
/**
* Accesses YouTube's various sections.
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} action - Type of action.
* @param {object} args - Action argumenets.
* @param {Innertube} session
* @param {string} action
* @param {object} 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 && ![ 'home_feed', 'lyrics',
'music_playlist', 'playlist', 'trending' ].includes(action))
throw new Utils.InnertubeError('You are not signed in');
const data = { context: session.context };
switch (action) {
@@ -86,6 +100,10 @@ async function browse(session, action, args = {}) {
case 'home_feed':
data.browseId = 'FEwhat_to_watch';
break;
case 'trending':
data.browseId = 'FEtrending';
args.params && (data.params = args.params);
break;
case 'subscriptions_feed':
data.browseId = 'FEsubscriptions';
break;
@@ -96,7 +114,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,9 +128,8 @@ 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);
const response = await session.request.post('/browse', JSON.stringify(data)).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
return {
@@ -122,17 +139,49 @@ async function browse(session, action, args = {}) {
};
}
/**
* Endpoints used to report content.
*
* @param {Innertube} session
* @param {string} action
* @param {object} args
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
*/
async function flag(session, action, args = {}) {
if (!session.logged_in) throw new Utils.InnertubeError('You are not signed in');
const data = { context: session.context };
switch (action) {
case 'flag/flag':
data.action = args.action;
break;
case 'flag/get_form':
data.params = args.params;
break;
default:
throw new Utils.InnertubeError('Invalid action', action);
}
const response = await session.request.post(`/${action}`, JSON.stringify(data)).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
return {
success: true,
status_code: response.status,
data: response.data
};
}
/**
* Account settings endpoints.
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} action - Type of action.
* @param {object} args - Action argumenets.
* @param {Innertube} session
* @param {string} action
* @param {object} args
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
*/
async function account(session, action, args = {}) {
if (!session.logged_in) throw new Error('You are not signed in');
if (!session.logged_in) throw new Utils.InnertubeError('You are not signed in');
const data = {};
switch (action) {
@@ -147,10 +196,10 @@ async function account(session, action, args = {}) {
default:
throw new Utils.InnertubeError('Invalid action', action);
}
const response = await session.YTRequester.post(`/${action}`, JSON.stringify(data)).catch((error) => error);
const response = await session.request.post(`/${action}`, JSON.stringify(data)).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
return {
success: true,
status_code: response.status,
@@ -161,10 +210,10 @@ async function account(session, action, args = {}) {
/**
* Accesses YouTube Music endpoints (/youtubei/v1/music/).
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} action - Type of action.
* @param {object} args - Action arguments.
* @todo Implement more actions.
* @param {Innertube} session
* @param {string} action
* @param {object} args
* @todo Implement more endpoints.
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
*/
async function music(session, action, args) {
@@ -174,8 +223,7 @@ async function music(session, action, args) {
context.client.clientVersion = Constants.YTMUSIC_VERSION;
context.client.clientName = 'WEB_REMIX';
let data;
let data = {};
switch (action) {
case 'get_search_suggestions':
data.context = context;
@@ -184,8 +232,8 @@ 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);
const response = await session.request.post(`/music/${action}`, JSON.stringify(data)).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
return {
@@ -198,7 +246,7 @@ async function music(session, action, args) {
/**
* Searches a given query on YouTube/YTMusic.
*
* @param {Innertube} session - A valid Innertube session.
* @param {Innertube} session
* @param {string} client - YouTube client: YOUTUBE | YTMUSIC
* @param {object} args - Search arguments.
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
@@ -220,16 +268,15 @@ 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);
throw new Utils.InnertubeError('Invalid client', client);
}
const requester = client == 'YOUTUBE' && session.YTRequester || session.YTMRequester;
const response = await requester.post('/search', JSON.stringify(data)).catch((error) => error);
const response = await session.request.post('/search', JSON.stringify(data)).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
return {
@@ -242,13 +289,13 @@ async function search(session, client, args = {}) {
/**
* Interacts with YouTube's notification system.
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} action - Type of action.
* @param {object} args - Action arguments.
* @param {Innertube} session
* @param {string} action
* @param {object} args
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
*/
async function notifications(session, action, args = {}) {
if (!session.logged_in) throw new Error('You are not signed in');
if (!session.logged_in) throw new Utils.InnertubeError('You are not signed in');
const data = {};
switch (action) {
@@ -268,8 +315,8 @@ 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);
const response = await session.request.post(`/notification/${action}`, JSON.stringify(data)).catch((err) => err);
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
if (action === 'modify_channel_preference') return { success: true, status_code: response.status };
@@ -283,9 +330,9 @@ async function notifications(session, action, args = {}) {
/**
* Interacts with YouTube's livechat system.
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} action - Type of action.
* @param {object} args - Action arguments.
* @param {Innertube} session
* @param {string} action
* @param {object} args
* @returns {Promise.<{ success: boolean; data: object; message?: string }>}
*/
async function livechat(session, action, args = {}) {
@@ -298,7 +345,7 @@ async function livechat(session, action, args = {}) {
case 'live_chat/send_message':
data.context = session.context;
data.params = Proto.encodeMessageParams(args.channel_id, args.video_id);
data.clientMessageId = `ytjs-${Uuid.v4()}`;
data.clientMessageId = Uuid.v4();
data.richMessage = {
textSegments: [{ text: args.text }]
}
@@ -318,18 +365,18 @@ 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);
const response = await session.request.post(`/${action}`, JSON.stringify(data)).catch((err) => err);
if (response instanceof Error) return { success: false, message: response.message };
return { success: true, data: response.data };
}
/**
* Requests continuation for previously performed actions.
*
* @param {Innertube} session - A valid Innertube session.
* @param {object} args - Continuation arguments.
* @param {Innertube} session
* @param {object} args
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
*/
async function next(session, args = {}) {
@@ -356,15 +403,9 @@ 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 || 0,
message: response.message
};
const response = await session.request.post('/next', JSON.stringify(data)).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response?.status || 0, message: response.message };
return {
success: true,
@@ -376,38 +417,45 @@ async function next(session, args = {}) {
/**
* Retrieves video data.
*
* @param {Innertube} session - A valid Innertube session.
* @param {object} args - Request arguments.
* @param {Innertube} session
* @param {object} args
* @returns {Promise.<object>} - Video data.
*/
async function getVideoInfo(session, args = {}) {
const response = await session.YTRequester.post(`/player`, JSON.stringify(Constants.VIDEO_INFO_REQBODY(args.id, session.sts, session.context))).catch((err) => err);
if (response instanceof Error) throw new Error(`Could not get video info: ${response.message}`);
const response = await session.request.post(`/player`, JSON.stringify(Constants.VIDEO_INFO_REQBODY(args.id, session.sts, session.context))).catch((err) => err);
if (response instanceof Error) throw new Utils.InnertubeError(`Could not get video info: ${response.message}`);
return response.data;
}
/**
* Gets search suggestions.
*
* @param {Innertube} session - A valid innertube session
* @param {string} query - Search query
* @param {Innertube} session
* @param {string} query
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
*/
async function getYTSearchSuggestions(session, query) {
const response = await Axios.get(`${Constants.URLS.YT_SUGGESTIONS}search?client=firefox&ds=yt&q=${encodeURIComponent(query)}`,
Constants.DEFAULT_HEADERS(session)).catch((error) => error);
if (response instanceof Error) return {
success: false,
status_code: response.status,
message: response.message
};
return {
success: true,
status_code: response.status,
data: response.data
};
async function getSearchSuggestions(session, client, input) {
if (!['YOUTUBE', 'YTMUSIC'].includes(client))
throw new Utils.InnertubeError('Invalid client', client);
const response = await ({
'YOUTUBE': async () => {
const response = await Axios.get(`${Constants.URLS.YT_SUGGESTIONS}search?client=firefox&ds=yt&q=${encodeURIComponent(input)}`,
Constants.DEFAULT_HEADERS(session.config)).catch((error) => error);
return {
success: !(response instanceof Error),
status_code: response.status,
data: response?.data
};
},
'YTMUSIC': async () => {
const response = await music(session, 'get_search_suggestions', { input });
return response;
}
}[client])();
return response;
}
module.exports = { engage, browse, account, music, search, notifications, livechat, getVideoInfo, next, getYTSearchSuggestions };
module.exports = { engage, browse, account, flag, music, search, notifications, livechat, getVideoInfo, next, getSearchSuggestions };

View File

@@ -2,34 +2,43 @@
const Axios = require('axios');
const Constants = require('../utils/Constants');
const EventEmitter = require('events');
const Uuid = require('uuid');
class OAuth extends EventEmitter {
constructor(auth_info) {
super();
this.auth_info = auth_info;
this.refresh_interval = 5;
this.oauth_code_url = `${Constants.URLS.YT_BASE}/o/oauth2/device/code`;
this.oauth_token_url = `${Constants.URLS.YT_BASE}/o/oauth2/token`;
this.model_name = Constants.OAUTH.MODEL_NAME;
this.grant_type = Constants.OAUTH.GRANT_TYPE;
this.scope = Constants.OAUTH.SCOPE;
this.auth_script_regex = /<script id=\"base-js\" src=\"(.*?)\" nonce=".*?"><\/script>/;
this.identity_regex = /.+?={};var .+?={clientId:\"(?<id>.+?)\",.+?:\"(?<secret>.+?)\"},/;
if (auth_info.access_token) return;
this.#requestAuthCode();
class OAuth {
#scope = Constants.OAUTH.SCOPE;
#model_name = Constants.OAUTH.MODEL_NAME;
#grant_type = Constants.OAUTH.GRANT_TYPE;
#oauth_code_url = `${Constants.URLS.YT_BASE}/o/oauth2/device/code`;
#oauth_token_url = `${Constants.URLS.YT_BASE}/o/oauth2/token`;
#oauth_revoke_url = `${Constants.URLS.YT_BASE}/o/oauth2/revoke`;
#auth_info = {};
#refresh_interval = 5;
#ev = null;
constructor(ev) {
this.#ev = ev;
}
/**
* Starts the auth flow in case no valid credentials are available.
* @returns {Promise.<void>}
*/
async init(auth_info) {
this.#auth_info = auth_info;
if (!auth_info.access_token) {
this.#requestUserCode();
}
}
/**
* Asks the OAuth server for an auth code.
* Asks the OAuth server for a user code
* and verification URL.
*
* @returns {Promise.<void>}
*/
async #requestAuthCode() {
async #requestUserCode() {
const identity = await this.#getClientIdentity();
this.client_id = identity.id;
@@ -37,20 +46,15 @@ class OAuth extends EventEmitter {
const data = {
client_id: this.client_id,
scope: this.scope,
scope: this.#scope,
device_id: Uuid.v4(),
model_name: this.model_name
model_name: this.#model_name
};
const response = await Axios.post(this.oauth_code_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
const response = await Axios.post(this.#oauth_code_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
if (response instanceof Error) return this.#ev.emit('auth', { error: 'Could not obtain user code.', status: 'FAILED' });
if (response instanceof Error)
return this.emit('auth', {
error: 'Could not get auth code.',
status: 'FAILED'
});
this.emit('auth', {
this.#ev.emit('auth', {
code: response.data.user_code,
status: 'AUTHORIZATION_PENDING',
expires_in: response.data.expires_in,
@@ -65,7 +69,7 @@ class OAuth extends EventEmitter {
/**
* Waits for sign-in authorization.
*
* @param {string} device_code Client's device code.
* @param {string} device_code - Client's device code.
* @returns
*/
#waitForAuth(device_code) {
@@ -73,16 +77,12 @@ class OAuth extends EventEmitter {
client_id: this.client_id,
client_secret: this.client_secret,
code: device_code,
grant_type: this.grant_type
grant_type: this.#grant_type
};
setTimeout(async () => {
const response = await Axios.post(this.oauth_token_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
if (response instanceof Error)
return this.emit('auth', {
error: 'Could not get authentication token.',
status: 'FAILED'
});
const response = await Axios.post(this.#oauth_token_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
if (response instanceof Error) return this.#ev.emit('auth', { error: 'Could not get authentication token.', status: 'FAILED' });
if (response.data.error) {
switch (response.data.error) {
@@ -91,78 +91,97 @@ class OAuth extends EventEmitter {
this.#waitForAuth(device_code);
break;
case 'access_denied':
this.emit('auth', {
this.#ev.emit('auth', {
error: 'Access was denied.',
status: 'ACCESS_DENIED'
});
break;
case 'expired_token':
this.emit('auth', {
error: 'The device code has expired, requesting a new one.',
this.#ev.emit('auth', {
error: 'The user code has expired, requesting a new one.',
status: 'DEVICE_CODE_EXPIRED'
});
this.#requestAuthCode();
this.#requestUserCode();
break;
default:
}
} else {
const expiration_date = new Date(new Date().getTime() + response.data.expires_in * 1000);
this.emit('auth', {
credentials: {
access_token: response.data.access_token,
refresh_token: response.data.refresh_token,
expires: expiration_date,
},
token_type: response.data.token_type,
const credentials = {
access_token: response.data.access_token,
refresh_token: response.data.refresh_token,
expires: expiration_date,
};
this.#auth_info = credentials;
this.#ev.emit('auth', {
credentials,
status: 'SUCCESS'
});
}
}, 1000 * this.refresh_interval);
}, 1000 * this.#refresh_interval);
}
/**
* Refreshes the access token if necessary.
* @returns {Promise.<void>}
*/
async checkTokenValidity() {
if (this.shouldRefreshToken()) {
await this.#refreshAccessToken();
}
}
/**
* Gets a new access token using a refresh token.
* @returns {Promise.<{ credentials: { access_token: string; refresh_token: string; expires: Date }; status: string }>}
* @returns {Promise.<void>}
*/
async refreshAccessToken() {
async #refreshAccessToken() {
const identity = await this.#getClientIdentity();
const data = {
client_id: identity.id,
client_secret: identity.secret,
refresh_token: this.auth_info.refresh_token,
refresh_token: this.#auth_info.refresh_token,
grant_type: 'refresh_token',
};
const response = await Axios.post(this.oauth_token_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
if (response instanceof Error) {
this.emit('auth', {
const response = await Axios.post(this.#oauth_token_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
if (response instanceof Error)
return this.#ev.emit('update-credentials', {
error: 'Could not refresh access token.',
status: 'FAILED'
});
return {
credentials: {
access_token: this.auth_info.access_token,
refresh_token: this.auth_info.refresh_token,
expires: this.auth_info.expires
},
status: 'FAILED'
};
}
const expiration_date = new Date(new Date().getTime() + response.data.expires_in * 1000);
return {
credentials: {
refresh_token: this.auth_info.refresh_token,
access_token: response.data.access_token,
expires: expiration_date
},
token_type: response.data.token_type,
status: 'SUCCESS'
const credentials = {
access_token: response.data.access_token,
refresh_token: response.data.refresh_token || this.#auth_info.refresh_token,
expires: expiration_date,
};
this.#auth_info = credentials;
this.#ev.emit('update-credentials', {
credentials,
status: 'SUCCESS'
});
}
/**
* Revokes access token (note that the refresh token will also be revoked).
* @returns {Promise.<void>}
*/
async revokeAccessToken() {
const response = await Axios.post(`${this.#oauth_revoke_url}?token=${this.getAccessToken()}`, Constants.OAUTH.HEADERS).catch((error) => error);
return {
success: !(response instanceof Error),
status_code: response.status || 0
}
}
/**
@@ -175,24 +194,41 @@ class OAuth extends EventEmitter {
if (yttv_response instanceof Error) throw new Error(`Could not extract client identity: ${yttv_response.message}`);
// Here we download the script and extract the necessary data to proceed with the auth flow.
const url_body = this.auth_script_regex.exec(yttv_response.data)[1];
const url_body = Constants.OAUTH.REGEX.AUTH_SCRIPT.exec(yttv_response.data)[1];
const script_url = `${Constants.URLS.YT_BASE}/${url_body}`;
const response = await Axios.get(script_url, Constants.DEFAULT_HEADERS).catch((error) => error);
const response = await Axios.get(script_url, Constants.DEFAULT_HEADERS()).catch((error) => error);
if (response instanceof Error) throw new Error(`Could not extract client identity: ${response.message}`);
const client_identity = response.data.replace(/\n/g, '').match(this.identity_regex);
const client_identity = response.data.replace(/\n/g, '').match(Constants.OAUTH.REGEX.CLIENT_IDENTITY);
return client_identity.groups;
}
getAccessToken() {
return this.#auth_info.access_token;
}
getRefreshToken() {
return this.#auth_info.refresh_token;
}
/**
* Checks if the auth info is valid.
* @returns {boolean} true | false
*/
isValidAuthInfo() {
return this.#auth_info.hasOwnProperty('access_token')
&& this.#auth_info.hasOwnProperty('refresh_token')
&& this.#auth_info.hasOwnProperty('expires');
}
/**
* Checks access token validity.
* @returns {boolean} true | false
*/
isTokenValid() {
const timestamp = new Date(this.auth_info.expires).getTime();
const is_valid = new Date().getTime() < timestamp;
return is_valid;
shouldRefreshToken() {
const timestamp = new Date(this.#auth_info.expires).getTime();
return new Date().getTime() > timestamp;
}
}

View File

@@ -2,7 +2,7 @@
const QueryString = require('querystring');
class SigDecipher {
class Signature {
constructor(url, player) {
this.url = url;
this.player = player;
@@ -72,4 +72,4 @@ class SigDecipher {
}
}
module.exports = SigDecipher;
module.exports = Signature;

View File

@@ -5,11 +5,12 @@ const Actions = require('../core/Actions');
const Constants = require('../utils/Constants');
const YTDataItems = require('./youtube');
const YTMusicDataItems = require('./ytmusic');
const Proto = require('../proto');
class Parser {
constructor(session, data, args = {}) {
this.session = session;
this.data = data;
this.session = session;
this.args = args;
}
@@ -23,14 +24,23 @@ class Parser {
case 'YOUTUBE':
processed_data = ({
SEARCH: () => this.#processSearch(),
CHANNEL: () => this.#processChannel(),
PLAYLIST: () => this.#processPlaylist(),
VIDEO_INFO: () => this.#processVideoInfo()
SUBSFEED: () => this.#processSubscriptionFeed(),
HOMEFEED: () => this.#processHomeFeed(),
TRENDING: () => this.#processTrending(),
HISTORY: () => this.#processHistory(),
COMMENTS: () => this.#processComments(),
VIDEO_INFO: () => this.#processVideoInfo(),
NOTIFICATIONS: () => this.#processNotifications(),
SEARCH_SUGGESTIONS: () => this.#processSearchSuggestions(),
})[data_type]()
break;
case 'YTMUSIC':
processed_data = ({
SEARCH: () => this.#processMusicSearch(),
PLAYLIST: () => this.#processMusicPlaylist()
PLAYLIST: () => this.#processMusicPlaylist(),
SEARCH_SUGGESTIONS: () => this.#processMusicSearchSuggestions(),
})[data_type]();
break;
default:
@@ -47,13 +57,13 @@ class Parser {
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;
@@ -70,7 +80,7 @@ class Parser {
return parseItems(contents);
}
#processMusicSearch() {
const tabs = Utils.findNode(this.data, 'contents', 'tabs').tabs;
const contents = Utils.findNode(tabs, '0', 'contents', 5);
@@ -93,7 +103,7 @@ class Parser {
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)),
['Top result']: () => YTMusicDataItems.TopResultItem.parse(section.contents),
['Songs']: () => YTMusicDataItems.SongResultItem.parse(section.contents),
['Videos']: () => YTMusicDataItems.VideoResultItem.parse(section.contents),
['Featured playlists']: () => YTMusicDataItems.PlaylistResultItem.parse(section.contents),
@@ -108,7 +118,16 @@ class Parser {
return processed_data;
}
#processSearchSuggestions() {
return YTDataItems.SearchSuggestionItem.parse(this.data[1], this.data[0]);
}
#processMusicSearchSuggestions() {
const contents = this.data.contents[0].searchSuggestionsSectionRenderer.contents;
return YTMusicDataItems.MusicSearchSuggestionItem.parse(contents);
}
#processPlaylist() {
const details = this.data.sidebar.playlistSidebarRenderer.items[0];
@@ -250,6 +269,280 @@ class Parser {
return processed_data;
}
#processComments() {
if (!this.data.onResponseReceivedEndpoints)
throw new Utils.UnavailableContentError('Comments section not available', this.args);
const header = Utils.findNode(this.data, 'onResponseReceivedEndpoints', 'commentsHeaderRenderer', 5, false);
const comment_count = parseInt(header.commentsHeaderRenderer.countText.runs[0].text.replace(/,/g, ''));
const page_count = parseInt(comment_count / 20);
const parseComments = (data) => {
const items = Utils.findNode(data, 'onResponseReceivedEndpoints', 'commentRenderer', 4, false);
const response = {
page_count,
comment_count,
items: []
};
response.items = items.map((item) => {
const comment = YTDataItems.CommentThread.parseItem(item);
if (comment) {
comment.like = () => Actions.engage(this.session, 'comment/perform_comment_action', { comment_action: 'like', comment_id: comment.metadata.id, video_id: this.args.video_id }),
comment.dislike = () => Actions.engage(this.session, 'comment/perform_comment_action', { comment_action: 'dislike', comment_id: comment.metadata.id, video_id: this.args.video_id }),
comment.reply = (text) => Actions.engage(this.session, 'comment/create_comment_reply', { text, comment_id: comment.metadata.id, video_id: this.args.video_id });
comment.report = async () => {
const payload = Utils.findNode(item, 'commentThreadRenderer', 'params', 10, false);
const form = await Actions.flag(this.session, 'flag/get_form', { params: payload.params });
const action = Utils.findNode(form, 'actions', 'flagAction', 13, false);
const flag = await Actions.flag(this.session, 'flag/flag', { action: action.flagAction });
return flag;
};
comment.getReplies = async () => {
if (comment.metadata.reply_count === 0) throw new Utils.InnertubeError('This comment has no replies', comment);
const payload = Proto.encodeCommentRepliesParams(this.args.video_id, comment.metadata.id);
const next = await Actions.next(this.session, { continuation_token: payload });
return parseComments(next.data);
};
return comment;
}
}).filter((c) => c);
response.getContinuation = async () => {
const continuation_item = items.find((item) => item.continuationItemRenderer);
if (!continuation_item) throw new Utils.InnertubeError('You\'ve reached the end');
const is_reply = !!continuation_item.continuationItemRenderer.button;
const payload = Utils.findNode(continuation_item, 'continuationItemRenderer', 'token', is_reply && 5 || 3);
const next = await Actions.next(this.session, { continuation_token: payload.token });
return parseComments(next.data);
};
return response;
};
return parseComments(this.data);
}
#processHomeFeed() {
const contents = Utils.findNode(this.data, 'contents', 'videoRenderer', 9, false)
const parseItems = (contents) => {
const videos = YTDataItems.VideoItem.parse(contents);
const getContinuation = async () => {
const citem = contents.find((item) => item.continuationItemRenderer);
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
const response = await Actions.browse(this.session, 'continuation', { ctoken });
if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response);
return parseItems(response.data.onResponseReceivedActions[0].appendContinuationItemsAction.continuationItems);
}
return { videos, getContinuation };
}
return parseItems(contents);
}
#processSubscriptionFeed() {
const contents = Utils.findNode(this.data, 'contents', 'contents', 9, false);
const subsfeed = { items: [] };
const parseItems = (contents) => {
contents.forEach((section) => {
if (!section.itemSectionRenderer) return;
const section_contents = section.itemSectionRenderer.contents[0];
const section_title = section_contents.shelfRenderer.title.runs[0].text;
const section_items = section_contents.shelfRenderer.content.gridRenderer.items;
const items = YTDataItems.GridVideoItem.parse(section_items);
subsfeed.items.push({
date: section_title,
videos: items
});
});
subsfeed.getContinuation = async () => {
const citem = contents.find((item) => item.continuationItemRenderer);
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
const response = await Actions.browse(this.session, 'continuation', { ctoken });
if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response);
const ccontents = Utils.findNode(response.data, 'onResponseReceivedActions', 'itemSectionRenderer', 4, false);
subsfeed.items = [];
return parseItems(ccontents);
}
return subsfeed;
};
return parseItems(contents);
}
#processChannel() {
const tabs = this.data.contents.twoColumnBrowseResultsRenderer.tabs;
const metadata = this.data.metadata;
const home_tab = tabs.find((tab) => tab.tabRenderer.title == 'Home');
const home_contents = home_tab.tabRenderer.content.sectionListRenderer.contents;
const home_shelves = [];
home_contents.forEach((content) => {
if (content.itemSectionRenderer) {
const contents = content.itemSectionRenderer.contents[0];
const list = contents?.shelfRenderer?.content.horizontalListRenderer;
if (!list) return; // Ignores featured channels (for now only videos & playlists are supported)
const shelf = {
title: contents.shelfRenderer.title.runs[0].text,
content: []
};
shelf.content = list.items.map((item) => {
if (item.gridVideoRenderer) {
return YTDataItems.GridVideoItem.parseItem(item);
} else if (item.gridPlaylistRenderer) {
return YTDataItems.GridPlaylistItem.parseItem(item);
}
});
home_shelves.push(shelf);
}
});
const ch_info = YTDataItems.ChannelMetadata.parse(metadata);
return {
...ch_info,
content: {
// Home page of the channel, always available in the first request.
home_page: home_shelves,
// TODO: Implement these (note: they require additional requests)
getVideos: () => {},
getPlaylists: () => {},
getCommunity: () => {},
getChannels: () => {},
getAbout: () => {}
}
}
}
#processNotifications() {
const contents = this.data.actions[0].openPopupAction.popup.multiPageMenuRenderer.sections[0];
if (!contents.multiPageMenuNotificationSectionRenderer) throw new Utils.InnertubeError('No notifications', response);
const parseItems = (items) => {
const parsed_items = YTDataItems.NotificationItem.parse(items);
const getContinuation = async () => {
const citem = items.find((item) => item.continuationItemRenderer);
const ctoken = citem?.continuationItemRenderer?.continuationEndpoint?.getNotificationMenuEndpoint?.ctoken;
const response = await Actions.notifications(this.session, 'get_notification_menu', { ctoken });
if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response);
return parseItems(response.data.actions[0].appendContinuationItemsAction.continuationItems);
}
return { items: parsed_items, getContinuation };
}
return parseItems(contents.multiPageMenuNotificationSectionRenderer.items);
}
#processTrending() {
const tabs = Utils.findNode(this.data, 'contents', 'tabRenderer', 4, false);
const categories = {};
const trending = tabs.map((tab) => {
const tab_renderer = tab.tabRenderer;
const tab_content = tab_renderer?.content;
const category_title = tab_renderer.title.toLowerCase();
categories[category_title] = {};
if (tab_content) { // The “Now” category is always available
const contents = tab_content.sectionListRenderer.contents;
categories[category_title].content = contents.map((content) => {
const shelf = content.itemSectionRenderer.contents[0].shelfRenderer;
const parsed_shelf = YTDataItems.ShelfRenderer.parse(shelf);
return parsed_shelf;
});
} else { // The rest can only be fetched with additional calls
const params = tab_renderer.endpoint.browseEndpoint.params;
categories[category_title].getVideos = async () => {
const response = await Actions.browse(this.session, 'trending', { params });
if (!response.success) throw new Utils.InnertubeError('Could not retrieve category videos', response);
const tabs = Utils.findNode(response, 'contents', 'tabRenderer', 4, false);
const tab = tabs.find((tab) => tab.tabRenderer.title === tab_renderer.title);
const contents = tab.tabRenderer.content.sectionListRenderer.contents;
const items = Utils.findNode(contents, 'itemSectionRenderer', 'items', 8, false);
return YTDataItems.VideoItem.parse(items);
};
}
});
return categories;
}
#processHistory() {
const contents = Utils.findNode(this.data, 'contents', 'videoRenderer', 9, false)
const history = { items: [] };
const parseItems = (contents) => {
contents.forEach((section) => {
if (!section.itemSectionRenderer) return;
const header = section.itemSectionRenderer.header.itemSectionHeaderRenderer.title;
const section_title = header?.simpleText || header?.runs.map((run) => run.text).join('');
const contents = section.itemSectionRenderer.contents;
const section_items = YTDataItems.VideoItem.parse(contents);
history.items.push({
date: section_title,
videos: section_items
});
});
history.getContinuation = async () => {
const citem = contents.find((item) => item.continuationItemRenderer);
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
const response = await Actions.browse(this.session, 'continuation', { ctoken });
if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response);
history.items = [];
return parseItems(response.data.onResponseReceivedActions[0].appendContinuationItemsAction.continuationItems);
}
return history;
}
return parseItems(contents);
}
}
module.exports = Parser;

View File

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

View File

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

View File

@@ -0,0 +1,37 @@
'use strict';
const Constants = require('../../../utils/Constants');
class CommentThread {
static parseItem(item) {
if (item.commentThreadRenderer || item.commentRenderer) {
const comment = item?.commentThreadRenderer?.comment || item;
const replies = item?.commentThreadRenderer?.replies;
const like_btn = comment.commentRenderer?.actionButtons?.commentActionButtonsRenderer.likeButton;
const dislike_btn = comment.commentRenderer?.actionButtons?.commentActionButtonsRenderer.dislikeButton;
return {
text: comment.commentRenderer.contentText.runs.map((run) => run.text).join(''),
author: {
name: comment.commentRenderer.authorText.simpleText,
thumbnails: comment.commentRenderer.authorThumbnail.thumbnails,
channel_id: comment.commentRenderer.authorEndpoint.browseEndpoint.browseId,
channel_url: Constants.URLS.YT_BASE + comment.commentRenderer.authorEndpoint.browseEndpoint.canonicalBaseUrl
},
metadata: {
published: comment.commentRenderer.publishedTimeText.runs[0].text,
is_reply: !!item.commentRenderer,
is_liked: like_btn.toggleButtonRenderer.isToggled,
is_disliked: dislike_btn.toggleButtonRenderer.isToggled,
is_pinned: comment.commentRenderer.pinnedCommentBadge && true || false,
is_channel_owner: comment.commentRenderer.authorIsChannelOwner,
like_count: parseInt(like_btn?.toggleButtonRenderer?.accessibilityData?.accessibilityData.label.replace(/\D/g, '')),
reply_count: comment.commentRenderer.replyCount || 0,
id: comment.commentRenderer.commentId,
}
}
}
}
}
module.exports = CommentThread;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,8 @@ const VideoResultItem = require('./search/VideoResultItem');
const AlbumResultItem = require('./search/AlbumResultItem');
const ArtistResultItem = require('./search/ArtistResultItem');
const PlaylistResultItem = require('./search/PlaylistResultItem');
const MusicSearchSuggestionItem = require('./search/MusicSearchSuggestionItem');
const TopResultItem = require('./search/TopResultItem');
const PlaylistItem = require('./others/PlaylistItem');
module.exports = { SongResultItem, VideoResultItem, AlbumResultItem, ArtistResultItem, PlaylistResultItem, TopResultItem, PlaylistItem };
module.exports = { SongResultItem, VideoResultItem, AlbumResultItem, ArtistResultItem, PlaylistResultItem, MusicSearchSuggestionItem, TopResultItem, PlaylistItem };

View File

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

View File

@@ -7,8 +7,8 @@ class PlaylistResultItem {
static parseItem(item) {
const list_item = item.musicResponsiveListItemRenderer;
const watch_playlist_endpoint = list_item.overlay.musicItemThumbnailOverlayRenderer
.content.musicPlayButtonRenderer.playNavigationEndpoint.watchPlaylistEndpoint;
const watch_playlist_endpoint = list_item?.overlay?.musicItemThumbnailOverlayRenderer
?.content?.musicPlayButtonRenderer?.playNavigationEndpoint?.watchPlaylistEndpoint;
return {
id: watch_playlist_endpoint?.playlistId,

View File

@@ -19,11 +19,12 @@ class TopResultItem {
song: () => SongResultItem.parseItem(item),
video: () => VideoResultItem.parseItem(item),
artist: () => ArtistResultItem.parseItem(item),
album: () => ArtistResultItem.parseItem(item)
album: () => AlbumResultItem.parseItem(item),
single: () => AlbumResultItem.parseItem(item)
}[type])();
parsed_item.type = type;
parsed_item && (parsed_item.type = type);
return parsed_item;
}).filter((item) => item);
}

View File

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

1751
lib/proto/messages.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,66 +2,111 @@ syntax = "proto2";
package proto;
message NotificationPreferences {
string channel_id = 1;
required string channel_id = 1;
message Preference {
int32 index = 1;
required int32 index = 1;
}
Preference pref_id = 2;
int32 number_0 = 3;
int32 number_1 = 4;
optional int32 number_0 = 3;
optional int32 number_1 = 4;
}
message LiveMessageParams {
message Params {
message Ids {
string channel_id = 1;
string video_id = 2;
required string channel_id = 1;
required string video_id = 2;
}
Ids ids = 5;
}
Params params = 1;
int32 number_0 = 2;
int32 number_1 = 3;
optional int32 number_0 = 2;
optional int32 number_1 = 3;
}
message GetCommentsSectionParams {
message Context {
string video_id = 2;
}
Context ctx = 2;
required int32 unk_param = 3;
message Params {
optional string unk_token = 1;
message Options {
required string video_id = 4;
required int32 sort_by = 6;
required int32 type = 15;
}
message RepliesOptions {
required string comment_id = 2;
message UnkOpts {
required int32 unk_param = 1;
}
UnkOpts unkopts = 4;
optional string channel_id = 5;
required string video_id = 6;
required int32 unk_param_1 = 8;
required int32 unk_param_2 = 9;
}
optional Options opts = 4;
optional RepliesOptions replies_opts = 3;
optional int32 page = 5;
required string target = 8;
}
Params params = 6;
}
message CreateCommentParams {
string video_id = 2;
required string video_id = 2;
message Params {
int32 index = 1;
required int32 index = 1;
}
Params params = 5;
int32 number = 10;
required int32 number = 10;
}
message CreateCommentReplyParams {
string video_id = 2;
string comment_id = 4;
required string video_id = 2;
required string comment_id = 4;
message UnknownParams {
int32 unk_num = 1;
required int32 unk_num = 1;
}
UnknownParams params = 5;
int32 unk_num = 10;
optional int32 unk_num = 10;
}
message PeformCommentActionParams {
int32 type = 1;
int32 unk_num = 2;
required int32 type = 1;
optional int32 unk_num = 2;
string comment_id = 3;
string video_id = 5;
required string comment_id = 3;
required string video_id = 5;
int32 unk_num_1 = 6;
int32 unk_num_2 = 7;
optional int32 unk_num_1 = 6;
optional int32 unk_num_2 = 7;
string unk_num_3 = 9;
optional string unk_num_3 = 9;
int32 unk_num_4 = 10;
int32 unk_num_5 = 21;
optional int32 unk_num_4 = 10;
optional int32 unk_num_5 = 21;
string channel_id = 23;
int32 unk_num_6 = 30;
optional string channel_id = 23;
optional int32 unk_num_6 = 30;
}
message SearchFilter {

View File

@@ -6,6 +6,7 @@ module.exports = {
URLS: {
YT_BASE: 'https://www.youtube.com',
YT_BASE_API: 'https://www.youtube.com/youtubei/',
YT_STUDIO_BASE_API: 'https://studio.youtube.com/youtubei/',
YT_SUGGESTIONS: 'https://suggestqueries.google.com/complete/',
YT_MUSIC: 'https://music.youtube.com',
YT_MUSIC_BASE_API: 'https://music.youtube.com/youtubei/'
@@ -23,16 +24,20 @@ module.exports = {
'referer': `https://www.youtube.com/tv`,
'accept-language': 'en-US'
}
},
REGEX: {
AUTH_SCRIPT: /<script id=\"base-js\" src=\"(.*?)\" nonce=".*?"><\/script>/,
CLIENT_IDENTITY: /.+?={};var .+?={clientId:\"(?<id>.+?)\",.+?:\"(?<secret>.+?)\"},/
}
},
DEFAULT_HEADERS: (session) => {
DEFAULT_HEADERS: (config) => {
return {
headers: {
'Cookie': session.cookie,
'Cookie': config?.cookie || '',
'user-agent': Utils.getRandomUserAgent('desktop').userAgent,
'Referer': 'https://www.google.com/',
'Accept': 'text/html',
'Accept-Language': 'en-US,en',
'Accept-Language': `en-${config?.gl || 'US'}`,
'Accept-Encoding': 'gzip'
}
};
@@ -45,30 +50,9 @@ module.exports = {
'Referer': 'https://www.youtube.com',
'DNT': '?1'
},
INNERTUBE_HEADERS: (info) => {
const origin = info.ytmusic && 'https://music.youtube.com' || 'https://www.youtube.com';
const headers = {
'accept': '*/*',
'user-agent': Utils.getRandomUserAgent('desktop').userAgent,
'content-type': 'application/json',
'accept-language': 'en-US,en;q=0.9',
'x-goog-authuser': 0,
'x-goog-visitor-id': info.session.context.client.visitorData || '',
'x-youtube-client-name': 1,
'x-youtube-client-version': info.session.context.client.clientVersion,
'x-youtube-chrome-connected': 'source=Chrome,mode=0,enable_account_consistency=true,supervised=false,consistency_enabled_by_default=false',
'x-origin': origin,
'origin': origin
};
if (info.session.logged_in) {
headers.Cookie = info.session.cookie;
headers.authorization = info.session.cookie.length && info.session.auth_apisid || `Bearer ${info.session.access_token}`;
}
return headers
INNERTUBE_HEADERS_BASE: {
'accept': '*/*',
'content-type': 'application/json',
},
VIDEO_INFO_REQBODY: (id, sts, context) => {
return {
@@ -138,4 +122,4 @@ module.exports = {
TRANSLATE_1: 'function(d,e){for(var f',
TRANSLATE_2: 'function(d,e,f){var'
}
};
};

53
lib/utils/Request.js Normal file
View File

@@ -0,0 +1,53 @@
'use strict';
const Axios = require('axios');
const Utils = require('./Utils');
const Constants = require('./Constants');
class Request {
constructor (session) {
this.session = session;
this.instance = Axios.create({
baseURL: Constants.URLS.YT_BASE_API + session.version,
headers: Constants.INNERTUBE_HEADERS_BASE,
params: { key: session.key },
timeout: 15000
});
this.#setupInterceptor();
return this.instance;
}
#setupInterceptor() {
this.instance.interceptors.request.use((config) => {
const is_ytmusic = config.data.includes(Constants.URLS.YT_MUSIC);
config.headers['accept-language'] = `en-${this.session.config.gl || 'US'}`;
config.headers['x-goog-visitor-id'] = this.session.context.client.visitorData || ''
config.headers['x-youtube-client-version'] = this.session.context.client.clientVersion;
config.headers['x-origin'] = is_ytmusic && Constants.URLS.YT_MUSIC || Constants.URLS.YT_BASE;
config.headers['origin'] = is_ytmusic && Constants.URLS.YT_MUSIC || Constants.URLS.YT_BASE;
is_ytmusic && (config.baseURL = Constants.URLS.YT_MUSIC_BASE_API + this.session.version);
if (this.session.logged_in) {
const cookie = this.session.config.cookie;
const token = cookie
&& this.session.auth_apisid
|| this.session.access_token;
config.headers.cookie = cookie || '';
config.headers.authorization = cookie && token || `Bearer ${token}`;
!cookie && (delete config.params.key);
}
return config;
}, (error) => Promise.reject(error));
}
}
module.exports = Request;

View File

@@ -1,12 +1,11 @@
'use strict';
const Fs = require('fs');
const Crypto = require('crypto');
const UserAgent = require('user-agents');
const Flatten = require('flat');
function InnertubeError(message, info) {
this.info = info;
this.info = info || {};
this.stack = Error(message).stack;
}

90
package-lock.json generated
View File

@@ -1,17 +1,17 @@
{
"name": "youtubei.js",
"version": "1.4.0",
"version": "1.4.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "youtubei.js",
"version": "1.4.0",
"version": "1.4.2",
"license": "MIT",
"dependencies": {
"axios": "^0.21.4",
"flat": "^5.0.2",
"protons": "^2.0.3",
"protocol-buffers-encodings": "^1.1.1",
"user-agents": "^1.0.778",
"uuid": "^8.3.2"
},
@@ -91,25 +91,13 @@
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8="
},
"node_modules/multiformats": {
"version": "9.6.4",
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.6.4.tgz",
"integrity": "sha512-fCCB6XMrr6CqJiHNjfFNGT0v//dxOBMrOMqUIzpPc/mmITweLEyhvMpY9bF+jZ9z3vaMAau5E8B68DW77QMXkg=="
},
"node_modules/protocol-buffers-schema": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
"integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw=="
},
"node_modules/protons": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/protons/-/protons-2.0.3.tgz",
"integrity": "sha512-j6JikP/H7gNybNinZhAHMN07Vjr1i4lVupg598l4I9gSTjJqOvKnwjzYX2PzvBTSVf2eZ2nWv4vG+mtW8L6tpA==",
"node_modules/protocol-buffers-encodings": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/protocol-buffers-encodings/-/protocol-buffers-encodings-1.1.1.tgz",
"integrity": "sha512-5aFshI9SbhtcMiDiZZu3g2tMlZeS5lhni//AGJ7V34PQLU5JA91Cva7TIs6inZhYikS3OpnUzAUuL6YtS0CyDA==",
"dependencies": {
"protocol-buffers-schema": "^3.3.1",
"signed-varint": "^2.0.1",
"uint8arrays": "^3.0.0",
"varint": "^5.0.0"
"varint": "5.0.0"
}
},
"node_modules/signed-varint": {
@@ -120,14 +108,6 @@
"varint": "~5.0.0"
}
},
"node_modules/uint8arrays": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz",
"integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==",
"dependencies": {
"multiformats": "^9.4.2"
}
},
"node_modules/underscore": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.2.tgz",
@@ -142,9 +122,9 @@
}
},
"node_modules/user-agents": {
"version": "1.0.984",
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.984.tgz",
"integrity": "sha512-gFYUg9GRrUA5LPKBa+K2K6jML3VPseVxm2TzhfTMVpLuxYZGm4qM8egSfQ7DV8X4DTNwECHAhlwv6JWZnIsCHQ==",
"version": "1.0.993",
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.993.tgz",
"integrity": "sha512-15uxQ45RVVNSWLkW9V3KkHoQIp+3evKLAfJSe6WOYNLF897mn7m1LTMn4IC7n4CmviDlQJ/SKyCXEutcYo1rAQ==",
"dependencies": {
"dot-json": "^1.2.2",
"lodash.clonedeep": "^4.5.0"
@@ -159,9 +139,9 @@
}
},
"node_modules/varint": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/varint/-/varint-5.0.2.tgz",
"integrity": "sha512-lKxKYG6H03yCZUpAGOPOsMcGxd1RHCu1iKvEHYDPmTyq2HueGhD73ssNBqqQWfvYs04G9iUFRvmAVLW20Jw6ow=="
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/varint/-/varint-5.0.0.tgz",
"integrity": "sha1-2Ca4n3SQcy+rwMDtaT7Uddyynr8="
}
},
"dependencies": {
@@ -208,25 +188,13 @@
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8="
},
"multiformats": {
"version": "9.6.4",
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.6.4.tgz",
"integrity": "sha512-fCCB6XMrr6CqJiHNjfFNGT0v//dxOBMrOMqUIzpPc/mmITweLEyhvMpY9bF+jZ9z3vaMAau5E8B68DW77QMXkg=="
},
"protocol-buffers-schema": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
"integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw=="
},
"protons": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/protons/-/protons-2.0.3.tgz",
"integrity": "sha512-j6JikP/H7gNybNinZhAHMN07Vjr1i4lVupg598l4I9gSTjJqOvKnwjzYX2PzvBTSVf2eZ2nWv4vG+mtW8L6tpA==",
"protocol-buffers-encodings": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/protocol-buffers-encodings/-/protocol-buffers-encodings-1.1.1.tgz",
"integrity": "sha512-5aFshI9SbhtcMiDiZZu3g2tMlZeS5lhni//AGJ7V34PQLU5JA91Cva7TIs6inZhYikS3OpnUzAUuL6YtS0CyDA==",
"requires": {
"protocol-buffers-schema": "^3.3.1",
"signed-varint": "^2.0.1",
"uint8arrays": "^3.0.0",
"varint": "^5.0.0"
"varint": "5.0.0"
}
},
"signed-varint": {
@@ -237,14 +205,6 @@
"varint": "~5.0.0"
}
},
"uint8arrays": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz",
"integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==",
"requires": {
"multiformats": "^9.4.2"
}
},
"underscore": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.2.tgz",
@@ -259,9 +219,9 @@
}
},
"user-agents": {
"version": "1.0.984",
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.984.tgz",
"integrity": "sha512-gFYUg9GRrUA5LPKBa+K2K6jML3VPseVxm2TzhfTMVpLuxYZGm4qM8egSfQ7DV8X4DTNwECHAhlwv6JWZnIsCHQ==",
"version": "1.0.993",
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.993.tgz",
"integrity": "sha512-15uxQ45RVVNSWLkW9V3KkHoQIp+3evKLAfJSe6WOYNLF897mn7m1LTMn4IC7n4CmviDlQJ/SKyCXEutcYo1rAQ==",
"requires": {
"dot-json": "^1.2.2",
"lodash.clonedeep": "^4.5.0"
@@ -273,9 +233,9 @@
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
},
"varint": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/varint/-/varint-5.0.2.tgz",
"integrity": "sha512-lKxKYG6H03yCZUpAGOPOsMcGxd1RHCu1iKvEHYDPmTyq2HueGhD73ssNBqqQWfvYs04G9iUFRvmAVLW20Jw6ow=="
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/varint/-/varint-5.0.0.tgz",
"integrity": "sha1-2Ca4n3SQcy+rwMDtaT7Uddyynr8="
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "youtubei.js",
"version": "1.4.0",
"version": "1.4.2",
"description": "A full-featured library that allows you to get detailed info about any video, subscribe, unsubscribe, like, dislike, comment, search, download videos/music and much more!",
"main": "index.js",
"author": "LuanRT <luan.lrt4@gmail.com> (https://github.com/LuanRT)",
@@ -12,14 +12,17 @@
"scripts": {
"test": "node test"
},
"types": "./typings/index.d.ts",
"directories": {
"example": "examples",
"lib": "lib"
"test": "./test",
"typings": "./typings",
"examples": "./examples",
"lib": "./lib"
},
"dependencies": {
"axios": "^0.21.4",
"flat": "^5.0.2",
"protons": "^2.0.3",
"protocol-buffers-encodings": "^1.1.1",
"user-agents": "^1.0.778",
"uuid": "^8.3.2"
},
@@ -40,6 +43,7 @@
"youtube-downloader",
"innertube",
"innertubeapi",
"unofficial",
"downloader",
"livechat",
"dislike",

View File

@@ -7,6 +7,7 @@ module.exports = {
expected_ntoken: 'AxwyS-osUl1WhMUd1',
client_version: '2.20211101.01.00',
test_video_id: 'dQw4w9WgXcQ',
test_song_id: 'UGB_Bsm5Unk',
sig_decipher_sc: `fB={RP:function(a,b){a.splice(0,b)},
Td:function(a){a.reverse()},
kq:function(a,b){var c=a[0];a[0]=a[b%a.length];a[b%a.length]=c};fB.kq(a,35);fB.RP(a,2);fB.kq(a,46);fB.Td(a,6);`,

View File

@@ -1,75 +1,81 @@
'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();
'use strict';
const Fs = require('fs');
const Innertube = require('..');
const NToken = require('../lib/deciphers/NToken');
const Signature = require('../lib/deciphers/Signature');
const Constants = require('./constants');
let failed_tests_count = 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().catch((error) => error);
assert(!(homefeed instanceof Error), `should retrieve recommendations`, homefeed);
const ytsearch = await youtube.search('Carl Sagan - Documentary').catch((error) => error);
assert(!(ytsearch instanceof Error), `should search on YouTube`, ytsearch);
const ytmsearch = await youtube.search('Logic - Obediently Yours', { client: 'YTMUSIC' }).catch((error) => error);
assert(!(ytmsearch instanceof Error), `should search on YouTube Music`, ytmsearch);
const ytsearch_suggestions = await youtube.getSearchSuggestions('test', { client: 'YOUTUBE' }).catch((error) => error);
assert(!(ytsearch_suggestions instanceof Error), `should retrieve YouTube search suggestions`, ytsearch_suggestions);
const ytmsearch_suggestions = await youtube.getSearchSuggestions('test', { client: 'YTMUSIC' }).catch((error) => error);
assert(!(ytmsearch_suggestions instanceof Error), `should retrieve YouTube Music search suggestions`, ytmsearch_suggestions);
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' }).catch((error) => error);
assert(!(ytplaylist instanceof Error), `should retrieve and parse playlist with YouTube`, ytplaylist);
const ytmplaylist = await youtube.getPlaylist(ytmsearch?.results?.community_playlists[0].id, { client: 'YTMUSIC' }).catch((error) => error);
assert(!(ytmplaylist instanceof Error), `should retrieve and parse playlist with YouTube Music`, ytmplaylist);
const lyrics = await youtube.getLyrics(Constants.test_song_id).catch((error) => error);
assert(!(lyrics instanceof Error), `should retrieve song lyrics`, lyrics);
const video = await downloadVideo(Constants.test_video_id, youtube).catch((error) => error);
assert(!(video instanceof Error), `should download video (${Constants.test_video_id})`, video);
}
const n_token = new NToken(Constants.n_scramble_sc, Constants.original_ntoken).transform();
assert(n_token == Constants.expected_ntoken, `should transform n token into ${Constants.expected_ntoken}`, n_token);
const transformed_url = new Signature(Constants.test_url, { sig_decipher_sc: Constants.sig_decipher_sc }).decipher();
assert(transformed_url == Constants.expected_url, `should decipher signature`, transformed_url);
if (failed_tests_count > 0)
throw new Error(`${failed_tests_count} 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_count += 1);
!outcome && console.error('Error: ', data);
return outcome;
}
performTests();

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

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