Compare commits

...

61 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
luan.lrt4@gmail.com
7230a2d927 chore: fix typos 2022-04-13 01:51:03 -03:00
luan.lrt4@gmail.com
924693349c chore: remove unneeded file 2022-04-13 01:48:33 -03:00
luan.lrt4@gmail.com
1ab302319d refactor!: rewrite parser and refactor project structure, closes #19 2022-04-13 01:47:57 -03:00
luan.lrt4@gmail.com
bbc1d0135b deps: add new dependency 2022-04-04 13:59:34 -03:00
luan.lrt4@gmail.com
9c1e34c9ab feat: implement pagination, refactor some methods & better error handling 2022-04-04 13:56:22 -03:00
luan.lrt4@gmail.com
c5eea2b4ff feat: implement pagination for all endpoints 2022-04-04 13:52:59 -03:00
luan.lrt4@gmail.com
60130f4d0f refactor: add utility to access deep object properties 2022-04-04 13:51:27 -03:00
luan.lrt4@gmail.com
5090c572d5 chore(release): v1.3.8 2022-03-30 23:52:28 -03:00
luan.lrt4@gmail.com
c9c72d0f31 feat: add support for comment replies, like and dislike 2022-03-30 23:31:11 -03:00
luan.lrt4@gmail.com
7635f49191 chore: add comment reply/action prototbuf messages 2022-03-30 14:33:22 -03:00
luan.lrt4@gmail.com
c932e65dad chore: simplify livechat logic and fix yt search suggestions 2022-03-28 14:18:49 -03:00
luan.lrt4@gmail.com
23717aab11 chore: rephrase comment 2022-03-26 05:42:53 -03:00
luan.lrt4@gmail.com
85df28a7fb feat: add support for channels (WIP) 2022-03-26 05:35:16 -03:00
luan.lrt4@gmail.com
9f4970b3ee refactor: separate protobuf stuff from utilities 2022-03-26 05:33:49 -03:00
luan.lrt4@gmail.com
82bbc715ff fix: playlists and home feed should work when logged out 2022-03-23 03:18:40 -03:00
luan.lrt4@gmail.com
3ec111212c chore(docs): rephrase 2022-03-23 00:45:59 -03:00
luan.lrt4@gmail.com
7ca4b2bb45 chore(release): v1.3.6 2022-03-23 00:43:09 -03:00
luan.lrt4@gmail.com
8d411f25c8 fix: age restricted videos causing uncaught exceptions when logged out 2022-03-23 00:32:51 -03:00
luan.lrt4@gmail.com
80fe969917 refactor: use axios instances to simplify logic & improve code readability 2022-03-22 23:35:39 -03:00
luan.lrt4@gmail.com
13c94fbb8a chore: rephrase comment 2022-03-22 09:36:00 -03:00
luan.lrt4@gmail.com
60ce869054 fix: welp, let's try again 2022-03-22 09:33:08 -03:00
luan.lrt4@gmail.com
1268ac83a6 chore: use optional chaining, bleh 2022-03-22 09:18:52 -03:00
luan.lrt4@gmail.com
5e588d0db5 refactor: use continuation requests for video data 2022-03-22 09:10:25 -03:00
luan.lrt4@gmail.com
8b37bd99b1 chore: add note regarding getVideoInfo() 2022-03-22 05:51:55 -03:00
luan.lrt4@gmail.com
08741de831 fix: oops, wrong param 2022-03-22 05:50:07 -03:00
luan.lrt4@gmail.com
574a595a01 chore: remove unneeded endpoint var 2022-03-22 04:09:32 -03:00
luan.lrt4@gmail.com
16928ee71b chore: update metadata keys 2022-03-21 22:41:38 -03:00
luan.lrt4@gmail.com
de6283080b feat: return comment count in getDetails() 2022-03-21 22:39:41 -03:00
luan.lrt4@gmail.com
23ab8bca4d chore: improve parsing 2022-03-21 19:13:29 -03:00
luan.lrt4@gmail.com
068b86b410 fix: parsing error if streaming data is not available 2022-03-18 17:13:42 -03:00
LuanRT
0b001c0956 fix: getHomeFeed() should work when logged out 2022-03-09 04:10:03 -03:00
LuanRT
4c14662d42 chore(docs): fix typo 2022-03-09 04:07:56 -03:00
LuanRT
f1a9d5d77b chore(docs): fix typo 2022-03-07 19:56:48 -03:00
48 changed files with 5267 additions and 2216 deletions

View File

@@ -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
- [ ] I have provided sufficient information

View File

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

917
README.md

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,467 +0,0 @@
'use strict';
const Uuid = require('uuid');
const Axios = require('axios');
const Utils = require('./Utils');
const Constants = require('./Constants');
/**
* Performs direct interactions on YouTube.
*
* @param {Innertube} session A valid Innertube session.
* @param {string} engagement_type Type of engagement.
* @param {object} args Engagement arguments.
* @returns {Promise.<object>} { success: boolean, status_code: number } | { success: boolean, status_code: number, message: string }
*/
async function engage(session, engagement_type, args = {}) {
if (!session.logged_in) throw new Error('You are not signed-in');
let data;
switch (engagement_type) {
case 'like/like':
case 'like/dislike':
case 'like/removelike':
data = {
context: session.context,
target: {
videoId: args.video_id
}
};
break;
case 'subscription/subscribe':
case 'subscription/unsubscribe':
data = {
context: session.context,
channelIds: [args.channel_id],
params: engagement_type == 'subscription/subscribe' ? 'EgIIAhgA' : 'CgIIAhgA'
};
break;
case 'comment/create_comment':
data = {
context: session.context,
commentText: args.text,
createCommentParams: Utils.encodeCommentParams(args.video_id)
};
break;
default:
}
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/${engagement_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, id: args.video_id, data })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status
};
}
/**
* Accesses YouTube's various sections.
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} action_type - Type of action.
* @param {object} args - Action argumenets.
* @returns {Promise.<object>} { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string }
*/
async function browse(session, action_type, args = {}) {
if (!session.logged_in && (action_type != 'lyrics' || action_type != 'music_playlist'))
throw new Error('You are not signed-in');
let data;
switch (action_type) {
case 'account_notifications':
data = {
context: session.context,
browseId: 'SPaccount_notifications'
};
break;
case 'account_privacy':
data = {
context: session.context,
browseId: 'SPaccount_privacy'
};
break;
case 'history':
data = {
context: session.context,
browseId: 'FEhistory'
}
break;
case 'home_feed':
data = {
context: session.context,
browseId: 'FEwhat_to_watch'
};
break;
case 'subscriptions_feed':
data = {
context: session.context,
browseId: 'FEsubscriptions'
};
break;
case 'lyrics':
case 'music_playlist':
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
context.client.originalUrl = Constants.URLS.YT_MUSIC_URL;
context.client.clientVersion = Constants.YTMUSIC_VERSION;
context.client.clientName = 'WEB_REMIX';
data = {
context,
browseId: args.browse_id
}
break;
case 'playlist':
data = {
context: session.context,
browseId: args.browse_id
}
break;
default:
}
const client_domain = args.ytmusic && Constants.URLS.YT_MUSIC_URL || Constants.URLS.YT_BASE_URL;
const response = await Axios.post(`${client_domain}/youtubei/v1/browse${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, ytmusic: args.ytmusic })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
data: response.data
};
}
/**
* Account settings endpoints.
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} action_type - Type of action.
* @param {object} args - Action argumenets.
* @returns {Promise.<object>} { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string }
*/
async function account(session, action_type, args = {}) {
if (!session.logged_in) throw new Error('You are not signed-in');
let data;
switch (action_type) {
case 'account/account_menu':
data = { context: session.context };
break;
case 'account/set_setting':
data = {
context: session.context,
newValue: {
boolValue: args.new_value
},
settingItemId: args.setting_item_id
}
break;
default:
break;
}
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
data: response.data
};
}
/**
* Accesses YouTube Music endpoints under /youtubei/v1/music/.
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} action_type - Type of action.
* @param {object} args - Action arguments.
* @todo Implement more actions.
* @returns
*/
async function music(session, action_type, args) {
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
context.client.originalUrl = Constants.URLS.YT_MUSIC_URL;
context.client.clientVersion = Constants.YTMUSIC_VERSION;
context.client.clientName = 'WEB_REMIX';
let data;
switch (action_type) {
case 'get_search_suggestions':
data = {
context,
input: args.input || ''
};
break;
default:
break;
}
const response = await Axios.post(`${Constants.URLS.YT_MUSIC_URL}/youtubei/v1/music/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, ytmusic: true })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
data: response.data
};
}
/**
* Performs searches on YouTube.
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} client - YouTube client: YOUTUBE | YTMUSIC
* @param {object} args - Search arguments.
* @returns {Promise.<object>} - { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string }
*/
async function search(session, client, args = {}) {
if (!args.query) throw new Error('No query was provided');
let data;
switch (client) {
case 'YOUTUBE':
data = {
context: session.context,
params: Utils.encodeFilter(args.options.period, args.options.duration, args.options.order),
query: args.query
};
break;
case 'YTMUSIC':
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
context.client.originalUrl = Constants.URLS.YT_MUSIC_URL;
context.client.clientVersion = Constants.YTMUSIC_VERSION;
context.client.clientName = 'WEB_REMIX';
data = {
context: context,
query: args.query
};
break;
default:
break;
}
const response = await Axios.post(`${client === 'YOUTUBE' && Constants.URLS.YT_BASE_URL || Constants.URLS.YT_MUSIC_URL}/youtubei/v1/search${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, ytmusic: client === 'YTMUSIC' })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
data: response.data
};
}
/**
* Interacts with YouTube's notification system.
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} action_type - Type of action.
* @param {object} args - Action arguments.
* @returns {Promise.<object>} - { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string }
*/
async function notifications(session, action_type, args = {}) {
if (!session.logged_in) throw new Error('You are not signed-in');
let data;
switch (action_type) {
case 'modify_channel_preference':
let pref_types = { PERSONALIZED: 1, ALL: 2, NONE: 3 };
data = {
context: session.context,
params: Utils.encodeNotificationPref(args.channel_id, pref_types[args.pref.toUpperCase()])
};
break;
case 'get_notification_menu':
data = {
context: session.context,
notificationsMenuRequestType: 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX'
};
break;
case 'get_unseen_count':
data = {
context: session.context
};
break;
default:
}
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/notification/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
if (action_type === 'modify_channel_preference') return { success: true, status_code: response.status };
return {
success: true,
status_code: response.status,
data: response.data
};
}
/**
* Interacts with YouTube's livechat system.
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} action_type - Type of action.
* @param {object} args - Action arguments.
* @returns {Promise.<object>} - { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string }
*/
async function livechat(session, action_type, args = {}) {
let data;
switch (action_type) {
case 'live_chat/get_live_chat':
data = {
context: session.context,
continuation: args.ctoken
};
break;
case 'live_chat/send_message':
data = {
context: session.context,
params: Utils.encodeMessageParams(args.channel_id, args.video_id),
clientMessageId: `ytjs-${Uuid.v4()}`,
richMessage: {
textSegments: [{ text: args.text }]
}
};
break;
case 'live_chat/get_item_context_menu':
data = {
context: session.context
};
break;
case 'live_chat/moderate':
data = {
context: session.context,
params: args.cmd_params
};
break;
case 'updated_metadata':
data = {
context: session.context,
videoId: args.video_id
};
args.continuation && (data.continuation = args.continuation);
break;
default:
}
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, params: args.params })).catch((error) => error);
if (response instanceof Error) return { success: false, message: response.message };
return { success: true, data: response.data };
}
/**
* Gets detailed data for a video.
*
* @param {Innertube} session - A valid Innertube session.
* @param {object} args - Request arguments.
* @returns {Promise.<object>} - Video data.
*/
async function getVideoInfo(session, args = {}) {
let response;
!args.desktop &&
(response = await Axios.get(`${Constants.URLS.YT_WATCH_PAGE}?v=${args.id}&t=8s&pbj=1`, Constants.INNERTUBE_REQOPTS({ session, id: args.id, desktop: false })).catch((error) => error)) ||
(response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/player${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(Constants.VIDEO_INFO_REQBODY(args.id, session.sts, session.context)), Constants.INNERTUBE_REQOPTS({ session, id: args.id, desktop: true })).catch((error) => error));
if (response instanceof Error) throw new Error(`Could not get video info: ${response.message}`);
return response.data;
}
/**
* Requests continuation for previously performed actions.
*
* @param {Innertube} session - A valid Innertube session.
* @param {object} args - Continuation arguments.
* @returns {Promise.<object>} - { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string }
*/
async function getContinuation(session, args = {}) {
let data = { context: session.context };
args.continuation_token && (data.continuation = args.continuation_token);
if (args.video_id) {
data.videoId = args.video_id;
if (args.ytmusic) {
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
context.client.originalUrl = Constants.URLS.YT_MUSIC_URL;
context.client.clientVersion = Constants.YTMUSIC_VERSION;
context.client.clientName = 'WEB_REMIX';
data.context = context;
data.isAudioOnly = true;
data.tunerSettingValue = 'AUTOMIX_SETTING_NORMAL';
} else {
data.racyCheckOk = true;
data.contentCheckOk = false;
data.autonavState = 'STATE_NONE';
data.playbackContext = {
vis: 0,
lactMilliseconds: '-1'
}
data.captionsRequested = false;
}
}
const client_domain = args.ytmusic && Constants.URLS.YT_MUSIC_URL || Constants.URLS.YT_BASE_URL;
const response = await Axios.post(`${client_domain}/youtubei/v1/next${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, ytmusic: args.ytmusic })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
data: response.data
};
}
/**
* Gets search suggestions.
*
* @param {Innertube} session
* @param {string} query
* @returns
*/
async function getYTSearchSuggestions(session, query) {
const response = await Axios.get(`${Constants.URLS.YT_SUGGESTIONS}search?client=firefox&ds=yt&q=${query}`,
Constants.DEFAULT_HEADERS(session)).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
data: response.data
};
}
module.exports = { engage, browse, account, music, search, notifications, livechat, getVideoInfo, getContinuation, getYTSearchSuggestions };

View File

@@ -2,76 +2,88 @@
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 Request = require('./utils/Request');
const Constants = require('./utils/Constants');
const Proto = require('./proto');
const NToken = require('./deciphers/NToken');
const Signature = require('./deciphers/Signature');
class Innertube {
#oauth;
#player;
#retry_count;
/**
* ```js
* const Innertube = require('youtubei.js');
* const youtube = await new Innertube();
* ```
* @param {string} [cookie]
* @param {object} [config]
* @param {string} [config.gl]
* @param {string} [config.cookie]
* @returns {Innertube}
* @constructor
*/
constructor(cookie) {
this.cookie = cookie || '';
constructor(config) {
this.config = config || {};
this.#retry_count = 0;
return this.#init();
}
async #init() {
const response = await Axios.get(Constants.URLS.YT_BASE_URL, Constants.DEFAULT_HEADERS(this)).catch((error) => error);
if (response instanceof Error) throw new Error(`Could not retrieve Innertube session: ${response.message}`);
const response = await Axios.get(Constants.URLS.YT_BASE, Constants.DEFAULT_HEADERS(this.config)).catch((error) => error);
if (response instanceof Error) throw new Utils.InnertubeError('Could not retrieve Innertube session', { message: response.message, status_code: response.status || 0 });
try {
const data = JSON.parse(`{${Utils.getStringBetweenStrings(response.data, 'ytcfg.set({', '});')}}`);
if (data.INNERTUBE_CONTEXT) {
this.key = data.INNERTUBE_API_KEY;
this.context = data.INNERTUBE_CONTEXT;
const data = JSON.parse(`{${Utils.getStringBetweenStrings(response.data, 'ytcfg.set({', '});') || ''}}`);
if (data.INNERTUBE_CONTEXT) {
this.key = data.INNERTUBE_API_KEY;
this.version = data.INNERTUBE_API_VERSION;
this.context = data.INNERTUBE_CONTEXT;
this.player_url = data.PLAYER_JS_URL;
this.logged_in = data.LOGGED_IN;
this.sts = data.STS;
this.player_url = data.PLAYER_JS_URL;
this.logged_in = data.LOGGED_IN;
this.sts = data.STS;
this.context.client.hl = 'en';
this.context.client.gl = 'US';
this.context.client.hl = 'en';
this.context.client.gl = this.config.gl || 'US';
/**
* @event auth - Fired when signing in to an account.
* @event update-credentials - Fired when the access token is no longer valid.
* @type {EventEmitter}
*/
this.ev = new EventEmitter();
/**
* @event Innertube#auth - Fired when signing in to an account.
* @event Innertube#update-credentials - Fired when the access token is no longer valid.
* @type {EventEmitter}
*/
this.ev = new EventEmitter();
this.#oauth = new OAuth(this.ev);
this.#player = new Player(this);
await this.#player.init();
this.#player = new Player(this);
await this.#player.init();
if (this.logged_in && this.cookie.length > 1) {
this.auth_apisid = Utils.getStringBetweenStrings(this.cookie, 'PAPISID=', ';');
this.auth_apisid = Utils.generateSidAuth(this.auth_apisid);
}
this.#initMethods();
} else {
throw new Error('Could not retrieve Innertube session due to unknown reasons');
if (this.logged_in && this.config.cookie) {
this.auth_apisid = Utils.getStringBetweenStrings(this.config.cookie, 'PAPISID=', ';');
this.auth_apisid = Utils.generateSidAuth(this.auth_apisid);
}
} catch (err) {
this.request = new Request(this);
this.#initMethods();
} else {
this.#retry_count += 1;
if (this.#retry_count >= 10) throw new Error(`Could not retrieve Innertube session: ${err.message}`);
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 || 0
});
return this.#init();
}
return this;
@@ -86,7 +98,7 @@ class Innertube {
* Notify about activity from the channels you're subscribed to.
*
* @param {boolean} new_value
* @returns {Promise<{success: boolean; status_code: string; }>}
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
setSubscriptions: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SUBSCRIPTIONS, 'account_notifications', new_value),
@@ -94,7 +106,7 @@ class Innertube {
* Recommended content notifications.
*
* @param {boolean} new_value
* @returns {Promise<{success: boolean; status_code: string; }>}
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
setRecommendedVideos: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.RECOMMENDED_VIDEOS, 'account_notifications', new_value),
@@ -102,7 +114,7 @@ class Innertube {
* Notify about activity on your channel.
*
* @param {boolean} new_value
* @returns {Promise<{success: boolean; status_code: string; }>}
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
setChannelActivity: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.CHANNEL_ACTIVITY, 'account_notifications', new_value),
@@ -110,7 +122,7 @@ class Innertube {
* Notify about replies to your comments.
*
* @param {boolean} new_value
* @returns {Promise<{success: boolean; status_code: string; }>}
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
setCommentReplies: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.COMMENT_REPLIES, 'account_notifications', new_value),
@@ -118,7 +130,7 @@ class Innertube {
* Notify when others mention your channel.
*
* @param {boolean} new_value
* @returns {Promise<{success: boolean; status_code: string; }>}
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
setMentions: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.USER_MENTION, 'account_notifications', new_value),
@@ -126,7 +138,7 @@ class Innertube {
* Notify when others share your content on their channels.
*
* @param {boolean} new_value
* @returns {Promise<{success: boolean; status_code: string; }>}
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
setSharedContent: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SHARED_CONTENT, 'account_notifications', new_value)
},
@@ -135,7 +147,7 @@ class Innertube {
* If set to true, your subscriptions won't be visible to others.
*
* @param {boolean} new_value
* @returns {Promise<{success: boolean; status_code: string; }>}
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
setSubscriptionsPrivate: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SUBSCRIPTIONS_PRIVACY, 'account_privacy', new_value),
@@ -143,7 +155,7 @@ class Innertube {
* If set to true, saved playlists won't appear on your channel.
*
* @param {boolean} new_value
* @returns {Promise<{success: boolean; status_code: string; }>}
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
setSavedPlaylistsPrivate: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.PLAYLISTS_PRIVACY, 'account_privacy', new_value)
}
@@ -155,7 +167,7 @@ class Innertube {
* Likes a given video.
*
* @param {string} video_id
* @returns {Promise<{success: boolean; status_code: string; }>}
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
like: (video_id) => Actions.engage(this, 'like/like', { video_id }),
@@ -163,7 +175,7 @@ class Innertube {
* Diskes a given video.
*
* @param {string} video_id
* @returns {Promise<{success: boolean; status_code: string; }>}
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
dislike: (video_id) => Actions.engage(this, 'like/dislike', { video_id }),
@@ -171,7 +183,7 @@ class Innertube {
* Removes a like/dislike.
*
* @param {string} video_id
* @returns {Promise<{success: boolean; status_code: string; }>}
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
removeLike: (video_id) => Actions.engage(this, 'like/removelike', { video_id }),
@@ -180,7 +192,7 @@ class Innertube {
*
* @param {string} video_id
* @param {string} text
* @returns {Promise<{success: boolean; status_code: string; }>}
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
comment: (video_id, text) => Actions.engage(this, 'comment/create_comment', { video_id, text }),
@@ -188,7 +200,7 @@ class Innertube {
* Subscribes to a given channel.
*
* @param {string} channel_id
* @returns {Promise<{success: boolean; status_code: string; }>}
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
subscribe: (channel_id) => Actions.engage(this, 'subscription/subscribe', { channel_id }),
@@ -196,7 +208,7 @@ class Innertube {
* Unsubscribes from a given channel.
*
* @param {string} channel_id
* @returns {Promise<{success: boolean; status_code: string; }>}
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
unsubscribe: (channel_id) => Actions.engage(this, 'subscription/unsubscribe', { channel_id }),
@@ -206,9 +218,37 @@ class Innertube {
*
* @param {string} channel_id
* @param {string} type PERSONALIZED | ALL | NONE
* @returns {Promise<{success: boolean; status_code: string; }>}
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
changeNotificationPreferences: (channel_id, type) => Actions.notifications(this, 'modify_channel_preference', { channel_id, pref: type || 'NONE' }),
setNotificationPreferences: (channel_id, type) => Actions.notifications(this, 'modify_channel_preference', { channel_id, pref: type || 'NONE' }),
};
this.playlist = {
/**
* Creates a playlist.
*
* @param {string} title
* @param {string} video_id - Note that a video must be supplied, empty playlists cannot be created.
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
create: (title, video_id) => Actions.engage(this, 'playlist/create', { title, video_id }),
/**
* Deletes a given playlist.
*
* @param {string} playlist_id
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
delete: (playlist_id) => Actions.engage(this, 'playlist/delete', { playlist_id }),
/**
* Adds an array of videos to a given playlist.
*
* @param {string} playlist_id
* @param {Array.<string>} video_ids
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
addVideos: (playlist_id, video_ids) => Actions.engage(this, 'browse/edit_playlist', { action: 'ACTION_ADD_VIDEO', playlist_id, video_ids })
};
}
@@ -218,24 +258,25 @@ class Innertube {
* @param {string} setting_id
* @param {string} type
* @param {string} new_value
* @returns {Promise<{success: boolean; status_code: string; }>}
* @returns {Promise.<{ success: boolean; status_code: string; }>}
*/
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;
if (!response.success) return response;
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,
status_code: response.status_code,
status_code: set_setting.status_code,
}
}
@@ -250,45 +291,49 @@ class Innertube {
*/
signIn(auth_info = {}) {
return new Promise(async (resolve) => {
const oauth = new OAuth(auth_info);
if (auth_info.access_token) {
if (!oauth.isTokenValid()) {
const tokens = await oauth.refreshAccessToken();
auth_info.refresh_token = tokens.credentials.refresh_token;
auth_info.access_token = tokens.credentials.access_token;
this.ev.emit('update-credentials', { credentials: tokens.credentials, status: tokens.status });
}
this.#oauth.init(auth_info);
this.access_token = auth_info.access_token;
this.refresh_token = auth_info.refresh_token;
this.logged_in = true;
resolve();
} else {
oauth.on('auth', (data) => {
if (data.status === 'SUCCESS') {
this.ev.emit('auth', { credentials: data.credentials, status: data.status });
this.access_token = data.credentials.access_token;
this.refresh_token = data.credentials.refresh_token;
this.logged_in = true;
resolve();
} else {
this.ev.emit('auth', data);
}
});
if (this.#oauth.isValidAuthInfo()) {
await this.#oauth.checkTokenValidity();
this.#updateCredentials();
return resolve();
}
this.ev.on('auth', (data) => {
if (data.status === 'SUCCESS') {
this.#updateCredentials();
resolve();
}
});
});
}
#updateCredentials() {
this.access_token = this.#oauth.getAccessToken();
this.refresh_token = this.#oauth.getRefreshToken();
this.logged_in = true;
}
/**
* Signs out of your account.
* @returns {Promise.<{ success: boolean; status_code: number }>}
*/
async signOut() {
if (!this.logged_in) throw new Utils.InnertubeError('You are not signed in');
const response = await this.#oauth.revokeAccessToken();
response.success && (this.logged_in = false);
return response;
}
/**
* Returns information about the account being used.
* @returns {Promise<{ name: string; photo: Array<object>; country: string; language: string; }>}
* Retrieves account details.
* @returns {Promise.<{ name: string; photo: Array<object>; country: string; language: string; }>}
*/
async getAccountInfo() {
const response = await Actions.account(this, 'account/account_menu');
if (!response.success) throw new Error('Could not get account info');
if (!response.success) throw new Utils.InnertubeError('Could not get account info', response);
const menu = response.data.actions[0].openPopupAction.popup.multiPageMenuRenderer;
const menu = Utils.findNode(response, 'actions', 'multiPageMenuRenderer', 6, false);
return {
name: menu.header.activeAccountHeaderRenderer.accountName.simpleText,
@@ -308,114 +353,223 @@ class Innertube {
* @param {string} options.order - Filter results by order, can be: relevance | rating | age | views
* @param {string} options.duration - Filter video results by duration, can be: any | short | long
* @returns {Promise.<{ query: string; corrected_query: string; estimated_results: number; videos: [] } |
* { songs: []; videos: []; albums: []; playlists: [] }>}
* { results: { songs: []; videos: []; albums: []; community_playlists: [] } }>}
*/
async search(query, options = { client: 'YOUTUBE', period: 'any', order: 'relevance', duration: 'any' }) {
const response = await Actions.search(this, options.client, { query, options });
if (!response.success) throw new Error(`Could not search on YouTube: ${response.message}`);
const data = new Parser(this, response.data, {
client: options.client,
data_type: 'SEARCH',
query
if (!response.success) throw new Utils.InnertubeError('Could not search on YouTube', response);
const results = new Parser(this, response.data, {
query, client: options.client,
data_type: 'SEARCH'
}).parse();
return data;
return results;
}
/**
* Gets search suggestions.
* Retrieves search suggestions.
*
* @param {string} input - The search query.
* @param {string} [client='YOUTUBE'] - Client used to retrieve search suggestions, can be: `YOUTUBE` or `YTMUSIC`.
* @param {object} [options] - Search options.
* @param {string} [options.client='YOUTUBE'] - Client used to retrieve search suggestions, can be: `YOUTUBE` or `YTMUSIC`.
* @returns {Promise.<[{ text: string; bold_text: string }]>}
*/
async getSearchSuggestions(input, { client = 'YOUTUBE' }) {
if (client == 'YOUTUBE') {
const response = await Actions.getYTSearchSuggestions(this, input);
if (!response.success) throw new Error('Could not get search suggestions');
async getSearchSuggestions(input, options = { client: 'YOUTUBE' }) {
const response = await Actions.getSearchSuggestions(this, options.client, input);
if (!response.success) throw new Utils.InnertubeError('Could not get search suggestions', response);
if (options.client === 'YTMUSIC' && !response.data.contents) return [];
return response.data[1].map((item) => {
return {
text: item.trim(),
bold_text: response.data[0].trim()
};
});
} else if (client == 'YTMUSIC') {
const response = await Actions.music(this, 'get_search_suggestions', { input });
if (!response.success) throw new Error('Could not get search suggestions');
if (!response.data.contents) return [];
const contents = response.data.contents[0].searchSuggestionsSectionRenderer.contents;
return contents.map((item) => {
let suggestion;
if (item.historySuggestionRenderer) {
suggestion = item.historySuggestionRenderer.suggestion;
} else {
suggestion = item.searchSuggestionRenderer.suggestion;
}
return {
text: suggestion.runs.map((run) => run.text).join('').trim(),
bold_text: suggestion.runs[0].text.trim()
};
});
}
const suggestions = new Parser(this, response.data, {
input, client: options.client,
data_type: 'SEARCH_SUGGESTIONS'
}).parse();
return suggestions;
}
/**
* Gets details for a video.
*
* @param {string} video_id - The id of the video.
* @return {Promise.<{ title: string; description: string; thumbnail: []; metadata: {} }>}
*/
* Retrieves video info.
*
* @param {string} video_id - Video id
* @return {Promise.<{ title: string; description: string; thumbnail: []; metadata: object }>}
*/
async getDetails(video_id) {
if (!video_id) throw new Error('You must provide a video id');
if (!video_id) throw new Utils.MissingParamError('Video id is missing');
const data = await Actions.getVideoInfo(this, { id: video_id, is_desktop: false });
const refined_data = new Parser(this, data, { client: 'YOUTUBE', data_type: 'VIDEO_INFO', desktop_v: false }).parse();
const response = await Actions.getVideoInfo(this, { id: video_id });
const continuation = await Actions.next(this, { video_id });
continuation.success && (response.continuation = continuation.data);
if (refined_data.metadata.is_live_content) {
const data_continuation = await Actions.getContinuation(this, { video_id });
if (data_continuation.data.contents.twoColumnWatchNextResults.conversationBar) {
refined_data.getLivechat = () => new Livechat(this, data_continuation.data.contents.twoColumnWatchNextResults.conversationBar.liveChatRenderer.continuations[0].reloadContinuationData.continuation, refined_data.metadata.channel_id, video_id);
} else {
refined_data.getLivechat = () => { };
}
} else {
refined_data.getLivechat = () => { };
}
const details = new Parser(this, response, {
client: 'YOUTUBE',
data_type: 'VIDEO_INFO'
}).parse();
details.like = () => Actions.engage(this, 'like/like', { video_id });
details.dislike = () => Actions.engage(this, 'like/dislike', { video_id });
details.removeLike = () => Actions.engage(this, 'like/removelike', { video_id });
details.subscribe = () => Actions.engage(this, 'subscription/subscribe', { channel_id: details.metadata.channel_id });
details.unsubscribe = () => Actions.engage(this, 'subscription/unsubscribe', { channel_id: details.metadata.channel_id });
details.comment = (text) => Actions.engage(this, 'comment/create_comment', { video_id, text });
details.getComments = (sort_by) => this.getComments(video_id, sort_by);
details.getLivechat = () => new Livechat(this, continuation.data.contents?.twoColumnWatchNextResults?.conversationBar?.liveChatRenderer?.continuations?.find((continuation) => continuation.reloadContinuationData).reloadContinuationData.continuation, details.metadata.channel_id, video_id);
details.setNotificationPreferences = (type) => Actions.notifications(this, 'modify_channel_preference', { channel_id: details.metadata.channel_id, pref: type || 'NONE' });
refined_data.like = () => Actions.engage(this, 'like/like', { video_id });
refined_data.dislike = () => Actions.engage(this, 'like/dislike', { video_id });
refined_data.removeLike = () => Actions.engage(this, 'like/removelike', { video_id });
refined_data.subscribe = () => Actions.engage(this, 'subscription/subscribe', { channel_id: refined_data.metadata.channel_id });
refined_data.unsubscribe = () => Actions.engage(this, 'subscription/unsubscribe', { channel_id: refined_data.metadata.channel_id });
refined_data.comment = (text) => Actions.engage(this, 'comment/create_comment', { video_id, text });
refined_data.getComments = () => this.getComments(video_id);
refined_data.changeNotificationPreferences = (type) => Actions.notifications(this, 'modify_channel_preference', { channel_id: refined_data.metadata.channel_id, pref: type || 'NONE' });
return details;
}
/**
* Retrieves comments for a video.
*
* @param {string} video_id - Video id
* @param {string} [sort_by] - Can be: `TOP_COMMENTS` or `NEWEST_FIRST`.
* @return {Promise.<{ page_count: number; comment_count: number; items: []; }>}
*/
async getComments(video_id, sort_by) {
const payload = Proto.encodeCommentsSectionParams(video_id, {
sort_by: sort_by || 'TOP_COMMENTS'
});
const response = await Actions.next(this, { continuation_token: payload });
if (!response.success) throw new Utils.InnertubeError('Could not retrieve comments', response);
return refined_data;
const comments = new Parser(this, response.data, {
video_id,
client: 'YOUTUBE',
data_type: 'COMMENTS'
}).parse();
return comments;
}
/**
* Retrieves the lyrics for a given song if available.
*
* @param {string} video_id
* @returns {Promise.<string>} Song lyrics
*/
* Retrieves contents for a given channel. (WIP)
*
* @param {string} id - The id of the channel.
* @return {Promise.<{ title: string; description: string; metadata: object; content: object }>}
*/
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 channel_info = new Parser(this, response.data, {
client: 'YOUTUBE',
data_type: 'CHANNEL'
}).parse();
return channel_info;
}
/**
* Retrieves your watch history.
* @returns {Promise.<{ items: [{ date: string; videos: [] }] }>}
*/
async getHistory() {
const response = await Actions.browse(this, 'history');
if (!response.success) throw new Utils.InnertubeError('Could not retrieve watch history', response);
const history = new Parser(this, response, {
client: 'YOUTUBE',
data_type: 'HISTORY'
}).parse();
return history;
}
/**
* Retrieves 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 homefeed = new Parser(this, response, {
client: 'YOUTUBE',
data_type: 'HOMEFEED'
}).parse();
return homefeed;
}
/**
* Retrieves trending content.
* @returns {Promise.<{ now: { content: [{ title: string; videos: []; }] };
* music: { getVideos: Promise.<Array>; }; gaming: { getVideos: Promise.<Array>; };
* gaming: { getVideos: Promise.<Array>; }; }>}
*/
async getTrending() {
const response = await Actions.browse(this, 'trending');
if (!response.success) throw new Utils.InnertubeError('Could not retrieve trending content', response);
const trending = new Parser(this, response, {
client: 'YOUTUBE',
data_type: 'TRENDING'
}).parse();
return trending;
}
/**
* Retrieves your subscriptions 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 subsfeed = new Parser(this, response, {
client: 'YOUTUBE',
data_type: 'SUBSFEED'
}).parse();
return subsfeed;
}
/**
* Retrieves your notifications.
* @returns {Promise.<{ items: [{ title: string; sent_time: string; channel_name: string; channel_thumbnail: {}; video_thumbnail: {}; video_url: string; read: boolean; notification_id: string }] }>}
*/
async getNotifications() {
const response = await Actions.notifications(this, 'get_notification_menu');
if (!response.success) throw new Utils.InnertubeError('Could not fetch notifications', response);
const notifications = new Parser(this, response.data, {
client: 'YOUTUBE',
data_type: 'NOTIFICATIONS'
}).parse();
return notifications;
}
/**
* Retrieves unseen notifications count.
* @returns {Promise.<number>} unseen notifications count.
*/
async getUnseenNotificationsCount() {
const response = await Actions.notifications(this, 'get_unseen_count');
if (!response.success) throw new Utils.InnertubeError('Could not get unseen notifications count', response);
return response.data.unseenCount;
}
/**
* Retrieves lyrics for a given song if available.
*
* @param {string} video_id
* @returns {Promise.<string>} Song lyrics
*/
async getLyrics(video_id) {
const data_continuation = await Actions.getContinuation(this, { video_id: video_id, ytmusic: true });
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 = data_continuation.data.contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer
.watchNextTabbedResultsRenderer.tabs.find((obj) => obj.tabRenderer.title == 'Lyrics');
const response = await Actions.browse(this, 'lyrics', { ytmusic: true, browse_id: lyrics_tab.tabRenderer.endpoint.browseEndpoint.browseId });
if (!response.data.contents.sectionListRenderer) throw new Error(response.data.contents.messageRenderer.text.runs[0].text);
const lyrics = response.data.contents.sectionListRenderer.contents[0].musicDescriptionShelfRenderer.description.runs[0].text;
return lyrics;
const lyrics_tab = Utils.findNode(continuation, 'contents', 'Lyrics', 8, false);
const response = await Actions.browse(this, 'lyrics', { ytmusic: true, browse_id: lyrics_tab.endpoint?.browseEndpoint.browseId });
if (!response.success || !response.data?.contents?.sectionListRenderer) throw new Utils.UnavailableContentError('Lyrics not available', { video_id });
const lyrics = Utils.findNode(response.data, 'contents', 'runs', 6, false);
return lyrics.runs[0].text;
}
/**
@@ -430,207 +584,14 @@ class Innertube {
*/
async getPlaylist(playlist_id, options = { client: 'YOUTUBE' }) {
const response = await Actions.browse(this, options.client == 'YTMUSIC' ? 'music_playlist' : 'playlist', { ytmusic: options.client == 'YTMUSIC', browse_id: `VL${playlist_id}` });
const data = new Parser(this, response.data, { client: options.client, data_type: 'PLAYLIST' }).parse();
return data;
}
/**
* Gets the comments section of a video.
*
* @param {string} video_id - The id of the video.
* @param {string} [token] - Continuation token (optional).
* @return {Promise.<[{ comments: []; comment_count: string }]>
*/
async getComments(video_id, token) {
let comment_section_token;
if (!token) {
const data_continuation = await Actions.getContinuation(this, { video_id });
const item_section_renderer = data_continuation.data.contents.twoColumnWatchNextResults.results.results.contents.find((item) => item.itemSectionRenderer);
comment_section_token = item_section_renderer.itemSectionRenderer.contents[0].continuationItemRenderer.continuationEndpoint.continuationCommand.token;
}
const response = await Actions.getContinuation(this, { continuation_token: comment_section_token || token });
if (!response.success) throw new Error('Could not fetch comments section');
const comments_section = { comments: [] };
!token && (comments_section.comment_count = response.data.onResponseReceivedEndpoints[0].reloadContinuationItemsCommand.continuationItems && response.data.onResponseReceivedEndpoints[0].reloadContinuationItemsCommand.continuationItems[0].commentsHeaderRenderer.countText.runs[0].text || 'N/A');
let continuation_token;
!token && (continuation_token = response.data.onResponseReceivedEndpoints[1].reloadContinuationItemsCommand.continuationItems.find((item) => item.continuationItemRenderer).continuationItemRenderer.continuationEndpoint.continuationCommand.token) ||
(continuation_token = response.data.onResponseReceivedEndpoints[0].appendContinuationItemsAction.continuationItems.find((item) => item.continuationItemRenderer).continuationItemRenderer.continuationEndpoint.continuationCommand.token);
comments_section.getContinuation = () => this.getComments(video_id, continuation_token);
let contents;
!token && (contents = response.data.onResponseReceivedEndpoints[1].reloadContinuationItemsCommand.continuationItems) ||
(contents = response.data.onResponseReceivedEndpoints[0].appendContinuationItemsAction.continuationItems);
contents.forEach((thread) => {
if (!thread.commentThreadRenderer) return;
const comment = {
text: thread.commentThreadRenderer.comment.commentRenderer.contentText.runs.map((t) => t.text).join(' '),
author: {
name: thread.commentThreadRenderer.comment.commentRenderer.authorText.simpleText,
thumbnail: thread.commentThreadRenderer.comment.commentRenderer.authorThumbnail.thumbnails,
channel_id: thread.commentThreadRenderer.comment.commentRenderer.authorEndpoint.browseEndpoint.browseId
},
metadata: {
published: thread.commentThreadRenderer.comment.commentRenderer.publishedTimeText.runs[0].text,
is_liked: thread.commentThreadRenderer.comment.commentRenderer.isLiked,
is_channel_owner: thread.commentThreadRenderer.comment.commentRenderer.authorIsChannelOwner,
like_count: thread.commentThreadRenderer.comment.commentRenderer.voteCount && thread.commentThreadRenderer.comment.commentRenderer.voteCount.simpleText || 'N/A',
reply_count: thread.commentThreadRenderer.comment.commentRenderer.replyCount || 0,
id: thread.commentThreadRenderer.comment.commentRenderer.commentId,
}
};
comments_section.comments.push(comment);
});
return comments_section;
}
/**
* Returns your watch history.
* @returns {Promise.<[{ id: string; title: string; channel: string; metadata: {} }]>}
*/
async getHistory() {
const response = await Actions.browse(this, 'history');
const contents = response.data.contents.twoColumnBrowseResultsRenderer.tabs[0]
.tabRenderer.content.sectionListRenderer.contents;
const history = [];
contents.forEach((section) => {
if (!section.itemSectionRenderer) return;
const section_items = section.itemSectionRenderer.contents;
section_items.forEach((item) => {
const content = {
id: item.videoRenderer.videoId,
title: item.videoRenderer.title.runs.map((run) => run.text).join(' '),
channel: item.videoRenderer.shortBylineText && item.videoRenderer.shortBylineText.runs[0].text || 'N/A',
metadata: {
view_count: item.videoRenderer.viewCountText && item.videoRenderer.viewCountText.simpleText || 'N/A',
thumbnail: item.videoRenderer.thumbnail && item.videoRenderer.thumbnail.thumbnails.slice(-1)[0] || [],
moving_thumbnail: item.videoRenderer.richThumbnail && item.videoRenderer.richThumbnail.movingThumbnailRenderer.movingThumbnailDetails.thumbnails[0] || [],
published: item.videoRenderer.publishedTimeText && item.videoRenderer.publishedTimeText.simpleText || 'N/A',
duration: item.videoRenderer.lengthText && item.videoRenderer.lengthText.simpleText || 'N/A',
badges: item.videoRenderer.badges && item.videoRenderer.badges.map((badge) => badge.metadataBadgeRenderer.label) || 'N/A',
owner_badges: item.videoRenderer.ownerBadges && item.videoRenderer.ownerBadges.map((badge) => badge.metadataBadgeRenderer.tooltip) || 'N/A'
}
};
history.push(content);
});
});
return history;
}
/**
* Returns YouTube's home feed.
* @returns {Promise.<[{ id: string; title: string; channel: string; metadata: {} }]>}
*/
async getHomeFeed() {
const response = await Actions.browse(this, 'home_feed');
if (!response.success) throw new Error('Could not get home feed');
const contents = response.data.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.richGridRenderer.contents;
return contents.map((item) => {
const content = item.richItemRenderer && item.richItemRenderer.content.videoRenderer &&
item.richItemRenderer.content || undefined;
if (content) return {
id: content.videoRenderer.videoId,
title: content.videoRenderer.title.runs.map((run) => run.text).join(' '),
channel: content.videoRenderer.shortBylineText && content.videoRenderer.shortBylineText.runs[0].text || 'N/A',
metadata: {
view_count: content.videoRenderer.viewCountText && content.videoRenderer.viewCountText.simpleText || 'N/A',
thumbnail: content.videoRenderer.thumbnail && content.videoRenderer.thumbnail.thumbnails.slice(-1)[0] || [],
moving_thumbnail: content.videoRenderer.richThumbnail && content.videoRenderer.richThumbnail.movingThumbnailRenderer.movingThumbnailDetails.thumbnails[0] || [],
published: content.videoRenderer.publishedTimeText && content.videoRenderer.publishedTimeText.simpleText || 'N/A',
badges: content.videoRenderer.badges && content.videoRenderer.badges.map((badge) => badge.metadataBadgeRenderer.label) || [],
owner_badges: content.videoRenderer.ownerBadges && content.videoRenderer.ownerBadges.map((badge) => badge.metadataBadgeRenderer.tooltip) || []
}
}
}).filter((video) => video);
}
/**
* Returns your subscription feed.
* @returns {Promise.<{ today: []; yesterday: []; this_week: [] }>}
*/
async getSubscriptionsFeed() {
const response = await Actions.browse(this, 'subscriptions_feed');
if (!response.success) throw new Error('Could not get subscriptions feed');
const contents = response.data.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents;
const subscriptions_feed = {};
contents.forEach((section) => {
if (!section.itemSectionRenderer) return;
const section_contents = section.itemSectionRenderer.contents[0];
const section_items = section_contents.shelfRenderer.content.gridRenderer.items;
const key = section_contents.shelfRenderer.title.runs[0].text;
subscriptions_feed[key.toLowerCase().replace(/ +/g, '_')] = [];
section_items.forEach((item) => {
const content = {
id: item.gridVideoRenderer.videoId,
title: item.gridVideoRenderer.title.runs.map((run) => run.text).join(' '),
channel: item.gridVideoRenderer.shortBylineText && item.gridVideoRenderer.shortBylineText.runs[0].text || 'N/A',
metadata: {
view_count: item.gridVideoRenderer.viewCountText && item.gridVideoRenderer.viewCountText.simpleText || 'N/A',
thumbnail: item.gridVideoRenderer.thumbnail && item.gridVideoRenderer.thumbnail.thumbnails.slice(-1)[0] || [],
published: item.gridVideoRenderer.publishedTimeText && item.gridVideoRenderer.publishedTimeText.simpleText || 'N/A',
badges: item.gridVideoRenderer.badges && item.gridVideoRenderer.badges.map((badge) => badge.metadataBadgeRenderer.label) || 'N/A',
owner_badges: item.gridVideoRenderer.ownerBadges && item.gridVideoRenderer.ownerBadges.map((badge) => badge.metadataBadgeRenderer.tooltip) || 'N/A'
}
};
subscriptions_feed[key.toLowerCase().replace(/ +/g, '_')].push(content);
});
});
return subscriptions_feed;
}
/**
* Retrieves your notifications.
* @returns {Promise.<[{ title: string; sent_time: string; channel_name: string; channel_thumbnail: {}; video_thumbnail: {}; video_url: string; read: boolean; notification_id: string }]>}
*/
async getNotifications() {
const response = await Actions.notifications(this, 'get_notification_menu');
if (!response.success) throw new Error('Could not fetch notifications');
const contents = response.data.actions[0].openPopupAction.popup.multiPageMenuRenderer.sections[0];
if (!contents.multiPageMenuNotificationSectionRenderer) return { error: 'You don\'t have any notification.' };
return contents.multiPageMenuNotificationSectionRenderer.items.map((notification) => {
if (!notification.notificationRenderer) return;
notification = notification.notificationRenderer;
return {
title: notification.shortMessage.simpleText,
sent_time: notification.sentTimeText.simpleText,
channel_name: notification.contextualMenu.menuRenderer.items[1].menuServiceItemRenderer.text.runs[1].text,
channel_thumbnail: notification.thumbnail.thumbnails[0],
video_thumbnail: notification.videoThumbnail.thumbnails[0],
video_url: `https://youtu.be/${notification.navigationEndpoint.watchEndpoint.videoId}`,
read: notification.read,
notification_id: notification.notificationId,
};
}).filter((notification_block) => notification_block);
}
/**
* Returns unseen notifications count.
* @returns {Promise.<number>} unseen notifications count.
*/
async getUnseenNotificationsCount() {
const response = await Actions.notifications(this, 'get_unseen_count');
if (!response.success) throw new Error('Could not get unseen notifications count');
return response.data.unseenCount;
if (!response.success) throw new Utils.InnertubeError('Could not get playlist', response);
const playlist = new Parser(this, response.data, {
client: options.client,
data_type: 'PLAYLIST'
}).parse();
return playlist;
}
/**
@@ -651,15 +612,15 @@ class Innertube {
format.url = format.url || format.signatureCipher || format.cipher;
if (format.signatureCipher || format.cipher) {
format.url = new SigDecipher(format.url, this.#player).decipher();
format.url = new Signature(format.url, this.#player).decipher();
}
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();
@@ -706,7 +667,7 @@ class Innertube {
* An alternative to {@link download}.
* Returns deciphered streaming data.
*
* @param {string} id - The id of the video.
* @param {string} id - Video id
* @param {object} options - Download options.
* @param {string} options.quality - Video quality; 360p, 720p, 1080p, etc....
* @param {string} options.type - Download type, can be: video, audio or videoandaudio
@@ -718,9 +679,9 @@ class Innertube {
options.type = options.type || 'videoandaudio';
options.format = options.format || 'mp4';
const data = await Actions.getVideoInfo(this, { id, desktop: true });
const data = await Actions.getVideoInfo(this, { id });
const streaming_data = this.#chooseFormat(options, data);
if (!streaming_data.selected_format) throw new Error('Could not find any suitable format.');
if (!streaming_data.selected_format) throw new Utils.NoStreamingDataError('Could not find any suitable format.', { id, options });
return streaming_data;
}
@@ -728,7 +689,7 @@ class Innertube {
/**
* Downloads a given video. If you only need the direct download link take a look at {@link getStreamingData}.
*
* @param {string} id - The id of the video.
* @param {string} id - Video id
* @param {object} options - Download options.
* @param {string} options.quality - Video quality; 360p, 720p, 1080p, etc....
* @param {string} options.type - Download type, can be: video, audio or videoandaudio
@@ -736,7 +697,7 @@ class Innertube {
* @return {ReadableStream}
*/
download(id, options = {}) {
if (!id) throw new Error('Missing video id');
if (!id) throw new Utils.MissingParamError('Video id is missing');
options.quality = options.quality || '360p';
options.type = options.type || 'videoandaudio';
@@ -746,7 +707,7 @@ class Innertube {
let cancelled = false;
const stream = new Stream.PassThrough();
Actions.getVideoInfo(this, { id, desktop: true }).then(async (video_data) => {
Actions.getVideoInfo(this, { id }).then(async (video_data) => {
if (video_data.playabilityStatus.status === 'LOGIN_REQUIRED')
return stream.emit('error', { message: 'You must login to download age-restricted videos.', error_type: 'LOGIN_REQUIRED', playability_status: video_data.playabilityStatus.status });
if (!video_data.streamingData)
@@ -757,7 +718,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) {

View File

@@ -1,200 +0,0 @@
'use strict';
const Axios = require('axios');
const Constants = require('./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_URL}/o/oauth2/device/code`;
this.oauth_token_url = `${Constants.URLS.YT_BASE_URL}/o/oauth2/token`;
this.guide_url = `${Constants.URLS.YT_BASE_URL}/youtubei/v1/guide`;
this.model_name = Constants.OAUTH.MODEL_NAME;
this.grant_type = Constants.OAUTH.GRANT_TYPE;
this.scope = Constants.OAUTH.SCOPE;
this.auth_script_regex = /<script id=\"base-js\" src=\"(.*?)\" nonce=".*?"><\/script>/;
this.identity_regex = /.+?={};var .+?={clientId:\"(?<id>.+?)\",.+?:\"(?<secret>.+?)\"},/;
if (auth_info.access_token) return;
this.#requestAuthCode();
}
/**
* Asks the OAuth server for an auth code.
* @returns {Promise.<void>}
*/
async #requestAuthCode() {
const identity = await this.#getClientIdentity();
this.client_id = identity.id;
this.client_secret = identity.secret;
const data = {
client_id: this.client_id,
scope: this.scope,
device_id: Uuid.v4(),
model_name: this.model_name
};
const response = await Axios.post(this.oauth_code_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
if (response instanceof Error)
return this.emit('auth', {
error: 'Could not get auth code.',
status: 'FAILED'
});
this.emit('auth', {
code: response.data.user_code,
status: 'AUTHORIZATION_PENDING',
expires_in: response.data.expires_in,
verification_url: response.data.verification_url
});
this.refresh_interval = response.data.interval;
this.#waitForAuth(response.data.device_code);
}
/**
* Waits for sign-in authorization.
*
* @param {string} device_code Client's device code.
* @returns
*/
#waitForAuth(device_code) {
const data = {
client_id: this.client_id,
client_secret: this.client_secret,
code: device_code,
grant_type: this.grant_type
};
setTimeout(async () => {
const response = await Axios.post(this.oauth_token_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
if (response instanceof Error)
return this.emit('auth', {
error: 'Could not get authentication token.',
status: 'FAILED'
});
if (response.data.error) {
switch (response.data.error) {
case 'slow_down':
case 'authorization_pending':
this.#waitForAuth(device_code);
break;
case 'access_denied':
this.emit('auth', {
error: 'Access was denied.',
status: 'ACCESS_DENIED'
});
break;
case 'expired_token':
this.emit('auth', {
error: 'The device code has expired, requesting a new one.',
status: 'DEVICE_CODE_EXPIRED'
});
this.#requestAuthCode();
break;
default:
}
} else {
const expiration_date = new Date(new Date().getTime() + response.data.expires_in * 1000);
this.emit('auth', {
credentials: {
access_token: response.data.access_token,
refresh_token: response.data.refresh_token,
expires: expiration_date,
},
token_type: response.data.token_type,
status: 'SUCCESS'
});
}
}, 1000 * this.refresh_interval);
}
/**
* Gets a new access token using a refresh token.
* @returns {object.<{ credentials: { access_token: string; refresh_token: string; expires: Date }; status: string }>}
*/
async refreshAccessToken() {
const identity = await this.#getClientIdentity();
const data = {
client_id: identity.id,
client_secret: identity.secret,
refresh_token: this.auth_info.refresh_token,
grant_type: 'refresh_token',
};
const response = await Axios.post(this.oauth_token_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
if (response instanceof Error) {
this.emit('auth', {
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'
};
}
/**
* Gets client identity data.
* @returns {Promise.<{ id: string; secret: string }>}
*/
async #getClientIdentity() {
// This request is made to get the auth script url, hard-coding it isn't viable as it changes overtime.
const yttv_response = await Axios.get(`${Constants.URLS.YT_BASE_URL}/tv`, Constants.OAUTH.HEADERS).catch((error) => error);
if (yttv_response instanceof Error) throw new Error(`Could not extract client identity: ${yttv_response.message}`);
// Here we download the script and extract the necessary data to proceed with the auth flow.
const url_body = this.auth_script_regex.exec(yttv_response.data)[1];
const script_url = `${Constants.URLS.YT_BASE_URL}/${url_body}`;
const response = await Axios.get(script_url, Constants.DEFAULT_HEADERS).catch((error) => error);
if (response instanceof Error) throw new Error(`Could not extract client identity: ${response.message}`);
const client_identity = response.data.replace(/\n/g, '').match(this.identity_regex);
return client_identity.groups;
}
/**
* Checks access token validity.
* @returns {boolean} true | false
*/
isTokenValid() {
const timestamp = new Date(this.auth_info.expires).getTime();
const is_valid = new Date().getTime() < timestamp;
return is_valid;
}
}
module.exports = OAuth;

View File

@@ -1,287 +0,0 @@
'use strict';
const Utils = require('./Utils');
const Constants = require('./Constants');
/**
* Takes raw data from the Innertube API and refines it.
* Mainly used for video data and search results, as those are more complex to parse.
*/
class Parser {
constructor(session, data, args = {}) {
this.session = session;
this.data = data;
this.args = args;
}
parse() {
return this.args.client === 'YOUTUBE' ? ({
SEARCH: () => this.#parseVideoSearch(),
PLAYLIST: () => this.#parsePlaylist(),
VIDEO_INFO: () => this.#parseVideoInfo()
})[this.args.data_type]() : ({
SEARCH: () => this.#parseMusicSearch(),
PLAYLIST: () => this.#parseMusicPlaylist()
})[this.args.data_type]();
}
#parseVideoSearch() {
const response = {};
const contents = this.data.contents.twoColumnSearchResultsRenderer
.primaryContents.sectionListRenderer.contents[0].itemSectionRenderer
.contents;
// TODO: Implement search continuation
// const continuation_token = this.data.contents.twoColumnSearchResultsRenderer
// .primaryContents.sectionListRenderer.contents[1].continuationItemRenderer
// .continuationEndpoint.continuationCommand.token;
response.query = contents[0].showingResultsForRenderer && contents[0].showingResultsForRenderer.originalQuery.simpleText || this.args.query;
response.corrected_query = contents[0].showingResultsForRenderer && contents[0].showingResultsForRenderer.correctedQueryEndpoint.searchEndpoint.query || this.args.query;
response.estimated_results = parseInt(this.data.estimatedResults);
response.videos = contents.map((data) => {
if (!data.videoRenderer) return;
const video = data.videoRenderer;
return {
title: video.title.runs[0].text,
description: video.detailedMetadataSnippets && video.detailedMetadataSnippets[0].snippetText.runs.map((item) => item.text).join('') || 'N/A',
author: video.ownerText.runs[0].text,
id: video.videoId,
url: `https://youtu.be/${video.videoId}`,
channel_url: `${Constants.URLS.YT_BASE_URL}${video.ownerText.runs[0].navigationEndpoint.commandMetadata.webCommandMetadata.url}`,
metadata: {
view_count: video.viewCountText && video.viewCountText.simpleText || 'N/A',
short_view_count_text: {
simple_text: video.shortViewCountText && video.shortViewCountText.simpleText || 'N/A',
accessibility_label: video.shortViewCountText && (video.shortViewCountText.accessibility && video.shortViewCountText.accessibility.accessibilityData.label || 'N/A') || 'N/A',
},
thumbnails: video.thumbnail.thumbnails,
duration: {
seconds: Utils.timeToSeconds(video.lengthText && video.lengthText.simpleText || '0'),
simple_text: video.lengthText && video.lengthText.simpleText || 'N/A',
accessibility_label: video.lengthText && video.lengthText.accessibility.accessibilityData.label || 'N/A'
},
published: video.publishedTimeText && video.publishedTimeText.simpleText || 'N/A',
badges: video.badges && video.badges.map((item) => item.metadataBadgeRenderer.label) || 'N/A',
owner_badges: video.ownerBadges && video.ownerBadges.map((item) => item.metadataBadgeRenderer.tooltip) || 'N/A '
}
};
}).filter((video) => video);
return response;
}
#parsePlaylist() {
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 playlist_content = this.data.contents.twoColumnBrowseResultsRenderer.tabs[0]
.tabRenderer.content.sectionListRenderer.contents[0]
.itemSectionRenderer.contents[0].playlistVideoListRenderer.contents;
const items = playlist_content.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 && item.playlistVideoRenderer.lengthText.simpleText || '0'),
simple_text: item.playlistVideoRenderer.lengthText && item.playlistVideoRenderer.lengthText.simpleText || 'N/A',
accessibility_label: item.playlistVideoRenderer.lengthText && item.playlistVideoRenderer.lengthText.accessibility.accessibilityData.label || 'N/A'
},
thumbnail: item.playlistVideoRenderer.thumbnail.thumbnails,
}
});
return {
...metadata,
items
}
}
#parseMusicSearch() {
const tabs = this.data.contents.tabbedSearchResultsRenderer.tabs;
const contents = tabs[0].tabRenderer.content.sectionListRenderer.contents;
if (contents.length <= 1)
return { songs: [], videos: [], albums: [], playlists: [] };
const songs_ms = contents.find((content) => content.musicShelfRenderer.title.runs[0].text == 'Songs');
const songs = songs_ms.musicShelfRenderer.contents.map((item) => {
const list_item = item.musicResponsiveListItemRenderer;
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[6].text,
thumbnail: list_item.thumbnail.musicThumbnailRenderer.thumbnail,
getLyrics: () => this.session.getLyrics(list_item.playlistItemData.videoId)
};
}).filter((item) => item); // Filters out undefined items, which are usually generated by unavailable videos.
const videos_ms = contents.find((content) => content.musicShelfRenderer.title.runs[0].text == 'Videos');
const videos = videos_ms.musicShelfRenderer.contents.map((item) => {
const list_item = item.musicResponsiveListItemRenderer;
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[6].text,
thumbnail: list_item.thumbnail.musicThumbnailRenderer.thumbnail,
getLyrics: () => this.session.getLyrics(list_item.playlistItemData.videoId)
};
}).filter((item) => item);
const albums_ms = contents.find((content) => content.musicShelfRenderer.title.runs[0].text == 'Albums');
const albums = albums_ms.musicShelfRenderer.contents.map((item) => {
const list_item = item.musicResponsiveListItemRenderer;
return {
title: list_item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
author: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[2].text,
year: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs.find((run) => /^[12][0-9]{3}$/.test(run.text)).text,
thumbnail: list_item.thumbnail.musicThumbnailRenderer.thumbnail,
};
});
const playlists_ms = contents.find((content) => content.musicShelfRenderer.title.runs[0].text == 'Community playlists');
const playlists = playlists_ms.musicShelfRenderer.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 };
}
#parseMusicPlaylist() {
const details = this.data.header.musicDetailHeaderRenderer;
const metadata = {
title: details.title.runs[0].text,
description: details.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,
thumbnail: item_renderer.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails
}
}).filter((item) => item.id);
return {
...metadata,
items
}
}
#parseVideoInfo() {
const desktop_v = this.args.desktop_v;
const playability_status = desktop_v && this.data.playabilityStatus ||
this.data[2].playerResponse.playabilityStatus;
if (playability_status.status == 'ERROR')
throw new Error(`Could not retrieve details for this video: ${playability_status.status} - ${playability_status.reason}`);
const details = desktop_v && this.data.videoDetails ||
this.data[2].playerResponse.videoDetails;
const microformat = desktop_v && this.data.microformat.playerMicroformatRenderer ||
this.data[2].playerResponse.microformat.playerMicroformatRenderer;
const streaming_data = desktop_v && this.data.streamingData ||
this.data[2].playerResponse.streamingData;
const response = {
id: '',
title: '',
description: '',
thumbnail: [],
metadata: {}
};
const mf_raw_data = Object.entries(microformat);
const dt_raw_data = Object.entries(details);
mf_raw_data.forEach((entry) => {
const key = Utils.camelToSnake(entry[0]);
if (Constants.METADATA_KEYS.includes(key)) {
key == 'view_count' && (response.metadata[key] = parseInt(entry[1])) ||
key == 'owner_profile_url' && (response.metadata.channel_url = entry[1]) ||
key == 'owner_channel_name' && (response.metadata.channel_name = entry[1]) ||
(response.metadata[key] = entry[1]);
} else {
response[key] = entry[1];
}
});
dt_raw_data.forEach((entry) => {
const key = Utils.camelToSnake(entry[0]);
if (Constants.BLACKLISTED_KEYS.includes(key)) return;
if (Constants.METADATA_KEYS.includes(key)) {
key == 'view_count' && (response.metadata[key] = parseInt(entry[1])) ||
(response.metadata[key] = entry[1]);
} else {
key == 'short_description' && (response.description = entry[1]) ||
key == 'thumbnail' && (response.thumbnail = entry[1].thumbnails.slice(-1)[0]) ||
key == 'video_id' && (response.id = entry[1]) ||
(response[key] = entry[1]);
}
});
if (!desktop_v) {
const dislike_available = this.data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1]
.slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[1].slimMetadataToggleButtonRenderer
.button.toggleButtonRenderer.defaultText.accessibility && true || false;
response.metadata.likes = parseInt(this.data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1]
.slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[0].slimMetadataToggleButtonRenderer
.button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, ''));
response.metadata.dislikes = dislike_available && parseInt(this.data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1]
.slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[1].slimMetadataToggleButtonRenderer
.button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, '')) || 0;
}
response.metadata.available_qualities = [...new Set(streaming_data.adaptiveFormats.filter(v => v.qualityLabel)
.map(v => v.qualityLabel).sort((a, b) => + a.replace(/\D/gi, '') - + b.replace(/\D/gi, '')))];
return response;
}
}
module.exports = Parser;

View File

@@ -1,193 +0,0 @@
'use strict';
const Fs = require('fs');
const Proto = require('protons');
const Crypto = require('crypto');
const UserAgent = require('user-agents');
/**
* Returns a random user agent.
*
* @param {string} type - mobile | desktop
* @returns {object}
*/
function getRandomUserAgent(type) {
switch (type) {
case 'mobile':
return new UserAgent(/Android/).data;
case 'desktop':
return new UserAgent({ deviceCategory: 'desktop' }).data;
default:
}
}
/**
* Generates an authentication token from a cookies' sid.
*
* @param {string} sid - Sid extracted from cookies
* @returns {string}
*/
function generateSidAuth(sid) {
const youtube = 'https://www.youtube.com';
const timestamp = Math.floor(new Date().getTime() / 1000);
const input = [timestamp, sid, youtube].join(' ');
let hash = Crypto.createHash('sha1');
let data = hash.update(input, 'utf-8');
let gen_hash = data.digest('hex');
return ['SAPISIDHASH', [timestamp, gen_hash].join('_')].join(' ');
}
/**
* Gets a string between two delimiters.
*
* @param {string} data - The data.
* @param {string} start_string - Start string.
* @param {string} end_string - End string.
*/
function getStringBetweenStrings(data, start_string, end_string) {
const regex = new RegExp(`${escapeStringRegexp(start_string)}(.*?)${escapeStringRegexp(end_string)}`, "s");
const match = data.match(regex);
return match ? match[1] : undefined;
}
function escapeStringRegexp(string) {
return string.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&").replace(/-/g, "\\x2d");
}
/**
* Converts time (h:m:s) to seconds.
*
* @param {string} time
* @returns {number} seconds
*/
function timeToSeconds(time) {
let params = time.split(':');
return parseInt(({
3: +params[0] * 3600 + +params[1] * 60 + +params[2],
2: +params[0] * 60 + +params[1],
1: +params[0]
})[params.length]);
}
/**
* Converts strings in camelCase to snake_case.
*
* @param {string} string The string in camelCase.
* @returns {string}
*/
function camelToSnake(string) {
return string[0].toLowerCase() + string.slice(1, string.length).replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
}
/**
* Encodes notification preferences protobuf.
*
* @param {string} channel_id
* @param {string} index
* @returns {string}
*/
function encodeNotificationPref(channel_id, index) {
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
const buf = youtube_proto.NotificationPreferences.encode({
channel_id,
pref_id: {
index
},
number_0: 0,
number_1: 4
});
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
/**
* Encodes livestream message protobuf.
*
* @param {string} channel_id
* @param {string} video_id
* @returns {string}
*/
function encodeMessageParams(channel_id, video_id) {
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
const buf = youtube_proto.LiveMessageParams.encode({
params: {
ids: {
channel_id,
video_id
}
},
number_0: 1,
number_1: 4
});
return Buffer.from(encodeURIComponent(Buffer.from(buf).toString('base64'))).toString('base64');
}
/**
* Encodes comment params protobuf.
*
* @param {string} video_id
* @returns {string}
*/
function encodeCommentParams(video_id) {
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
const buf = youtube_proto.CreateCommentParams.encode({
video_id,
params: {
index: 0
},
number: 7
});
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
/**
* Encodes search filter protobuf
*
* @param {string} period - Period in which a video is uploaded: any | hour | day | week | month | year
* @param {string} duration - The duration of a video: any | short | long
* @param {string} order - The order of the search results: relevance | rating | age | views
* @returns {string}
*/
function encodeFilter(period, duration, order) {
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
const periods = { 'any': null, 'hour': 1, 'day': 2, 'week': 3, 'month': 4, 'year': 5 };
const durations = { 'any': null, 'short': 1, 'long': 2 };
const orders = { 'relevance': null, 'rating': 1, 'age': 2, 'views': 3 };
const search_filter_buff = youtube_proto.SearchFilter.encode({
number: orders[order],
filter: {
param_0: periods[period],
param_1: (period == 'hour' && order == 'relevance') ? null : 1,
param_2: durations[duration]
}
});
return encodeURIComponent(Buffer.from(search_filter_buff).toString('base64'));
}
/**
* Turns the ntoken transform data into a valid json array
*
* @param {string} data
* @returns {string}
*/
function refineNTokenData(data) {
return data
.replace(/function\(d,e\)/g, '"function(d,e)').replace(/function\(d\)/g, '"function(d)')
.replace(/function\(\)/g, '"function()').replace(/function\(d,e,f\)/g, '"function(d,e,f)')
.replace(/\[function\(d,e,f\)/g, '["function(d,e,f)').replace(/,b,/g, ',"b",')
.replace(/,b/g, ',"b"').replace(/b,/g, '"b",').replace(/b]/g, '"b"]')
.replace(/\[b/g, '["b"').replace(/}]/g, '"]').replace(/},/g, '}",')
.replace(/""/g, '').replace(/length]\)}"/g, 'length])}');
}
module.exports = { getRandomUserAgent, generateSidAuth, getStringBetweenStrings, camelToSnake, timeToSeconds, encodeMessageParams, encodeCommentParams, encodeNotificationPref, encodeFilter, refineNTokenData };

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

@@ -0,0 +1,461 @@
'use strict';
const Uuid = require('uuid');
const Axios = require('axios');
const Proto = require('../proto');
const Utils = require('../utils/Utils');
const Constants = require('../utils/Constants');
/**
* Performs direct interactions on YouTube.
*
* @param {Innertube} session
* @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 Utils.InnertubeError('You are not signed in');
const data = { context: session.context };
switch (engagement_type) {
case 'like/like':
case 'like/dislike':
case 'like/removelike':
data.target = {
videoId: args.video_id
}
break;
case 'subscription/subscribe':
case 'subscription/unsubscribe':
data.channelIds = [args.channel_id];
data.params = engagement_type == 'subscription/subscribe' ? 'EgIIAhgA' : 'CgIIAhgA';
break;
case 'comment/create_comment':
data.commentText = args.text;
data.createCommentParams = Proto.encodeCommentParams(args.video_id);
break;
case 'comment/create_comment_reply':
data.createReplyParams = Proto.encodeCommentReplyParams(args.comment_id, args.video_id);
data.commentText = args.text;
break;
case 'comment/perform_comment_action':
const action = ({
like: () => Proto.encodeCommentActionParams(5, args.comment_id, args.video_id),
dislike: () => Proto.encodeCommentActionParams(4, args.comment_id, args.video_id),
})[args.comment_action]();
data.actions = [ action ];
break;
case 'playlist/create':
data.title = args.title;
data.videoIds = [args.video_id];
break;
case 'playlist/delete':
data.playlistId = args.playlist_id;
break;
case 'browse/edit_playlist':
data.playlistId = args.playlist_id;
data.actions = args.video_ids.map((id) => ({
action: args.action,
addedVideoId: id
}));
break;
default:
throw new Utils.InnertubeError('Invalid action', engagement_type);
}
const response = await session.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 {
success: true,
status_code: response.status
};
}
/**
* Accesses YouTube's various sections.
*
* @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 && ![ 'home_feed', 'lyrics',
'music_playlist', 'playlist', 'trending' ].includes(action))
throw new Utils.InnertubeError('You are not signed in');
const data = { context: session.context };
switch (action) {
case 'account_notifications':
data.browseId = 'SPaccount_notifications';
break;
case 'account_privacy':
data.browseId = 'SPaccount_privacy';
break;
case 'history':
data.browseId = 'FEhistory';
break;
case 'home_feed':
data.browseId = 'FEwhat_to_watch';
break;
case 'trending':
data.browseId = 'FEtrending';
args.params && (data.params = args.params);
break;
case 'subscriptions_feed':
data.browseId = 'FEsubscriptions';
break;
case 'lyrics':
case 'music_playlist':
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
context.client.originalUrl = Constants.URLS.YT_MUSIC;
context.client.clientVersion = Constants.YTMUSIC_VERSION;
context.client.clientName = 'WEB_REMIX';
data.context = context;
data.browseId = args.browse_id;
break;
case 'channel':
case 'playlist':
data.browseId = args.browse_id;
break;
case 'continuation':
data.continuation = args.ctoken;
break;
default:
throw new Utils.InnertubeError('Invalid action', action);
}
const 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 {
success: true,
status_code: response.status,
data: response.data
};
}
/**
* 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
* @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 Utils.InnertubeError('You are not signed in');
const data = {};
switch (action) {
case 'account/account_menu':
data.context = session.context;
break;
case 'account/set_setting':
data.context = session.context;
data.newValue = { boolValue: args.new_value };
data.settingItemId = args.setting_item_id;
break;
default:
throw new Utils.InnertubeError('Invalid action', action);
}
const response = await session.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
};
}
/**
* Accesses YouTube Music endpoints (/youtubei/v1/music/).
*
* @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) {
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
context.client.originalUrl = Constants.URLS.YT_MUSIC;
context.client.clientVersion = Constants.YTMUSIC_VERSION;
context.client.clientName = 'WEB_REMIX';
let data = {};
switch (action) {
case 'get_search_suggestions':
data.context = context;
data.input = args.input || '';
break;
default:
throw new Utils.InnertubeError('Invalid action', action);
}
const response = await session.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 {
success: true,
status_code: response.status,
data: response.data
};
}
/**
* Searches a given query on YouTube/YTMusic.
*
* @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 }>}
*/
async function search(session, client, args = {}) {
const data = { context: session.context };
switch (client) {
case 'YOUTUBE':
if (args.query) {
data.params = Proto.encodeSearchFilter(args.options.period, args.options.duration, args.options.order);
data.query = args.query;
} else {
data.continuation = args.ctoken;
}
break;
case 'YTMUSIC':
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
context.client.originalUrl = Constants.URLS.YT_MUSIC;
context.client.clientVersion = Constants.YTMUSIC_VERSION;
context.client.clientName = 'WEB_REMIX';
data.context = context;
data.query = args.query;
break;
default:
throw new Utils.InnertubeError('Invalid client', client);
}
const 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 {
success: true,
status_code: response.status,
data: response.data
};
}
/**
* Interacts with YouTube's notification system.
*
* @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 Utils.InnertubeError('You are not signed in');
const data = {};
switch (action) {
case 'modify_channel_preference':
const pref_types = { PERSONALIZED: 1, ALL: 2, NONE: 3 };
data.context = session.context;
data.params = Proto.encodeNotificationPref(args.channel_id, pref_types[args.pref.toUpperCase()]);
break;
case 'get_notification_menu':
data.context = session.context;
data.notificationsMenuRequestType = 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX';
args.ctoken && (data.ctoken = args.ctoken);
break;
case 'get_unseen_count':
data.context = session.context;
break;
default:
throw new Utils.InnertubeError('Invalid action', action);
}
const response = await session.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 };
return {
success: true,
status_code: response.status,
data: response.data
};
}
/**
* Interacts with YouTube's livechat system.
*
* @param {Innertube} session
* @param {string} action
* @param {object} args
* @returns {Promise.<{ success: boolean; data: object; message?: string }>}
*/
async function livechat(session, action, args = {}) {
const data = {};
switch (action) {
case 'live_chat/get_live_chat':
data.context = session.context;
data.continuation = args.ctoken;
break;
case 'live_chat/send_message':
data.context = session.context;
data.params = Proto.encodeMessageParams(args.channel_id, args.video_id);
data.clientMessageId = Uuid.v4();
data.richMessage = {
textSegments: [{ text: args.text }]
}
break;
case 'live_chat/get_item_context_menu':
data.context = session.context;
break;
case 'live_chat/moderate':
data.context = session.context;
data.params = args.cmd_params;
break;
case 'updated_metadata':
data.context = session.context;
data.videoId = args.video_id;
args.continuation && (data.continuation = args.continuation);
break;
default:
throw new Utils.InnertubeError('Invalid action', action);
}
const response = await session.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
* @param {object} args
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
*/
async function next(session, args = {}) {
let data = { context: session.context };
args.continuation_token && (data.continuation = args.continuation_token);
if (args.video_id) {
data.videoId = args.video_id;
if (args.ytmusic) {
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
context.client.originalUrl = Constants.URLS.YT_MUSIC;
context.client.clientVersion = Constants.YTMUSIC_VERSION;
context.client.clientName = 'WEB_REMIX';
data.context = context;
data.isAudioOnly = true;
data.tunerSettingValue = 'AUTOMIX_SETTING_NORMAL';
} else {
data.racyCheckOk = true;
data.contentCheckOk = false;
data.autonavState = 'STATE_NONE';
data.playbackContext = { vis: 0, lactMilliseconds: '-1' };
data.captionsRequested = false;
}
}
const 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,
status_code: response.status,
data: response.data
};
}
/**
* Retrieves video data.
*
* @param {Innertube} session
* @param {object} args
* @returns {Promise.<object>} - Video data.
*/
async function getVideoInfo(session, args = {}) {
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
* @param {string} query
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
*/
async function getSearchSuggestions(session, client, input) {
if (!['YOUTUBE', 'YTMUSIC'].includes(client))
throw new Utils.InnertubeError('Invalid client', client);
const response = await ({
'YOUTUBE': async () => {
const response = await Axios.get(`${Constants.URLS.YT_SUGGESTIONS}search?client=firefox&ds=yt&q=${encodeURIComponent(input)}`,
Constants.DEFAULT_HEADERS(session.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, flag, music, search, notifications, livechat, getVideoInfo, next, getSearchSuggestions };

View File

@@ -6,6 +6,10 @@ const EventEmitter = require('events');
class Livechat extends EventEmitter {
constructor(session, token, channel_id, video_id) {
super(session);
if (!token)
throw new Error('Could not retrieve livechat data');
this.ctoken = token;
this.session = session;
this.video_id = video_id;

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

@@ -0,0 +1,235 @@
'use strict';
const Axios = require('axios');
const Constants = require('../utils/Constants');
const Uuid = require('uuid');
class OAuth {
#scope = Constants.OAUTH.SCOPE;
#model_name = Constants.OAUTH.MODEL_NAME;
#grant_type = Constants.OAUTH.GRANT_TYPE;
#oauth_code_url = `${Constants.URLS.YT_BASE}/o/oauth2/device/code`;
#oauth_token_url = `${Constants.URLS.YT_BASE}/o/oauth2/token`;
#oauth_revoke_url = `${Constants.URLS.YT_BASE}/o/oauth2/revoke`;
#auth_info = {};
#refresh_interval = 5;
#ev = null;
constructor(ev) {
this.#ev = ev;
}
/**
* Starts the auth flow in case no valid credentials are available.
* @returns {Promise.<void>}
*/
async init(auth_info) {
this.#auth_info = auth_info;
if (!auth_info.access_token) {
this.#requestUserCode();
}
}
/**
* Asks the OAuth server for a user code
* and verification URL.
*
* @returns {Promise.<void>}
*/
async #requestUserCode() {
const identity = await this.#getClientIdentity();
this.client_id = identity.id;
this.client_secret = identity.secret;
const data = {
client_id: this.client_id,
scope: this.#scope,
device_id: Uuid.v4(),
model_name: this.#model_name
};
const response = await Axios.post(this.#oauth_code_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
if (response instanceof Error) return this.#ev.emit('auth', { error: 'Could not obtain user code.', status: 'FAILED' });
this.#ev.emit('auth', {
code: response.data.user_code,
status: 'AUTHORIZATION_PENDING',
expires_in: response.data.expires_in,
verification_url: response.data.verification_url
});
this.refresh_interval = response.data.interval;
this.#waitForAuth(response.data.device_code);
}
/**
* Waits for sign-in authorization.
*
* @param {string} device_code - Client's device code.
* @returns
*/
#waitForAuth(device_code) {
const data = {
client_id: this.client_id,
client_secret: this.client_secret,
code: device_code,
grant_type: this.#grant_type
};
setTimeout(async () => {
const response = await Axios.post(this.#oauth_token_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
if (response instanceof Error) return this.#ev.emit('auth', { error: 'Could not get authentication token.', status: 'FAILED' });
if (response.data.error) {
switch (response.data.error) {
case 'slow_down':
case 'authorization_pending':
this.#waitForAuth(device_code);
break;
case 'access_denied':
this.#ev.emit('auth', {
error: 'Access was denied.',
status: 'ACCESS_DENIED'
});
break;
case 'expired_token':
this.#ev.emit('auth', {
error: 'The user code has expired, requesting a new one.',
status: 'DEVICE_CODE_EXPIRED'
});
this.#requestUserCode();
break;
default:
}
} else {
const expiration_date = new Date(new Date().getTime() + response.data.expires_in * 1000);
const credentials = {
access_token: response.data.access_token,
refresh_token: response.data.refresh_token,
expires: expiration_date,
};
this.#auth_info = credentials;
this.#ev.emit('auth', {
credentials,
status: 'SUCCESS'
});
}
}, 1000 * this.#refresh_interval);
}
/**
* Refreshes the access token if necessary.
* @returns {Promise.<void>}
*/
async checkTokenValidity() {
if (this.shouldRefreshToken()) {
await this.#refreshAccessToken();
}
}
/**
* Gets a new access token using a refresh token.
* @returns {Promise.<void>}
*/
async #refreshAccessToken() {
const identity = await this.#getClientIdentity();
const data = {
client_id: identity.id,
client_secret: identity.secret,
refresh_token: this.#auth_info.refresh_token,
grant_type: 'refresh_token',
};
const response = await Axios.post(this.#oauth_token_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
if (response instanceof Error)
return this.#ev.emit('update-credentials', {
error: 'Could not refresh access token.',
status: 'FAILED'
});
const expiration_date = new Date(new Date().getTime() + response.data.expires_in * 1000);
const credentials = {
access_token: response.data.access_token,
refresh_token: response.data.refresh_token || this.#auth_info.refresh_token,
expires: expiration_date,
};
this.#auth_info = credentials;
this.#ev.emit('update-credentials', {
credentials,
status: 'SUCCESS'
});
}
/**
* Revokes access token (note that the refresh token will also be revoked).
* @returns {Promise.<void>}
*/
async revokeAccessToken() {
const response = await Axios.post(`${this.#oauth_revoke_url}?token=${this.getAccessToken()}`, Constants.OAUTH.HEADERS).catch((error) => error);
return {
success: !(response instanceof Error),
status_code: response.status || 0
}
}
/**
* Gets client identity data.
* @returns {Promise.<{ id: string; secret: string }>}
*/
async #getClientIdentity() {
// This request is made to get the auth script url, hard-coding it isn't viable as it changes overtime.
const yttv_response = await Axios.get(`${Constants.URLS.YT_BASE}/tv`, Constants.OAUTH.HEADERS).catch((error) => error);
if (yttv_response instanceof Error) throw new Error(`Could not extract client identity: ${yttv_response.message}`);
// Here we download the script and extract the necessary data to proceed with the auth flow.
const url_body = Constants.OAUTH.REGEX.AUTH_SCRIPT.exec(yttv_response.data)[1];
const script_url = `${Constants.URLS.YT_BASE}/${url_body}`;
const response = await Axios.get(script_url, Constants.DEFAULT_HEADERS()).catch((error) => error);
if (response instanceof Error) throw new Error(`Could not extract client identity: ${response.message}`);
const client_identity = response.data.replace(/\n/g, '').match(Constants.OAUTH.REGEX.CLIENT_IDENTITY);
return client_identity.groups;
}
getAccessToken() {
return this.#auth_info.access_token;
}
getRefreshToken() {
return this.#auth_info.refresh_token;
}
/**
* Checks if the auth info is valid.
* @returns {boolean} true | false
*/
isValidAuthInfo() {
return this.#auth_info.hasOwnProperty('access_token')
&& this.#auth_info.hasOwnProperty('refresh_token')
&& this.#auth_info.hasOwnProperty('expires');
}
/**
* Checks access token validity.
* @returns {boolean} true | false
*/
shouldRefreshToken() {
const timestamp = new Date(this.#auth_info.expires).getTime();
return new Date().getTime() > timestamp;
}
}
module.exports = OAuth;

View File

@@ -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() {
@@ -18,7 +18,7 @@ class Player {
this.sig_decipher_sc = this.#getSigDecipherCode(player_data);
this.ntoken_sc = this.#getNEncoder(player_data);
} else {
const response = await Axios.get(`${Constants.URLS.YT_BASE_URL}${this.session.player_url}`, { path: this.session.playerUrl, headers: { 'content-type': 'text/javascript', 'user-agent': Utils.getRandomUserAgent('desktop').userAgent } }).catch((error) => error);
const response = await Axios.get(`${Constants.URLS.YT_BASE}${this.session.player_url}`, { path: this.session.playerUrl, headers: { 'content-type': 'text/javascript', 'user-agent': Utils.getRandomUserAgent('desktop').userAgent } }).catch((error) => error);
if (response instanceof Error) throw new Error('Could not download player script: ' + response.message);
try {
@@ -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);

View File

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

View File

@@ -2,7 +2,7 @@
const QueryString = require('querystring');
class SigDecipher {
class Signature {
constructor(url, player) {
this.url = url;
this.player = player;
@@ -11,8 +11,8 @@ class SigDecipher {
}
/**
* Deciphers signature.
*/
* Deciphers signature.
*/
decipher() {
const args = QueryString.parse(this.url);
const functions = this.#getFunctions();
@@ -72,4 +72,4 @@ class SigDecipher {
}
}
module.exports = SigDecipher;
module.exports = Signature;

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

@@ -0,0 +1,548 @@
'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');
const Proto = require('../proto');
class Parser {
constructor(session, data, args = {}) {
this.data = data;
this.session = session;
this.args = args;
}
parse() {
const client = this.args.client;
const data_type = this.args.data_type
let processed_data;
switch (client) {
case 'YOUTUBE':
processed_data = ({
SEARCH: () => this.#processSearch(),
CHANNEL: () => this.#processChannel(),
PLAYLIST: () => this.#processPlaylist(),
SUBSFEED: () => this.#processSubscriptionFeed(),
HOMEFEED: () => this.#processHomeFeed(),
TRENDING: () => this.#processTrending(),
HISTORY: () => this.#processHistory(),
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(),
SEARCH_SUGGESTIONS: () => this.#processMusicSearchSuggestions(),
})[data_type]();
break;
default:
throw new Utils.InnertubeError('Invalid client');
}
return processed_data;
}
#processSearch() {
const contents = Utils.findNode(this.data, 'contents', 'contents', 5);
const processed_data = {};
const parseItems = (contents) => {
const content = contents[0].itemSectionRenderer.contents;
processed_data.query = content[0]?.showingResultsForRenderer?.originalQuery?.simpleText || this.args.query;
processed_data.corrected_query = content[0]?.showingResultsForRenderer?.correctedQueryEndpoint?.searchEndpoint?.query || 'N/A';
processed_data.estimated_results = parseInt(this.data.estimatedResults);
processed_data.videos = YTDataItems.VideoResultItem.parse(content);
processed_data.getContinuation = async () => {
const citem = contents.find((item) => item.continuationItemRenderer);
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
const response = await Actions.search(this.session, 'YOUTUBE', { ctoken });
if (!response.success) throw new Utils.InnertubeError('Could not get continuation', response);
const continuation_items = Utils.findNode(response.data, 'onResponseReceivedCommands', 'itemSectionRenderer', 4, false);
return parseItems(continuation_items);
};
return processed_data;
}
return parseItems(contents);
}
#processMusicSearch() {
const tabs = Utils.findNode(this.data, 'contents', 'tabs').tabs;
const contents = Utils.findNode(tabs, '0', 'contents', 5);
const did_you_mean_item = contents.find((content) => content.itemSectionRenderer);
const did_you_mean_renderer = did_you_mean_item?.itemSectionRenderer.contents[0].didYouMeanRenderer;
const processed_data = {
query: '',
corrected_query: '',
results: {}
};
processed_data.query = this.args.query;
processed_data.corrected_query = did_you_mean_renderer?.correctedQuery.runs.map((run) => run.text).join('') || 'N/A';
contents.forEach((content) => {
const section = content?.musicShelfRenderer;
if (section) {
const section_title = section.title.runs[0].text;
const section_items = ({
['Top result']: () => YTMusicDataItems.TopResultItem.parse(section.contents),
['Songs']: () => YTMusicDataItems.SongResultItem.parse(section.contents),
['Videos']: () => YTMusicDataItems.VideoResultItem.parse(section.contents),
['Featured playlists']: () => YTMusicDataItems.PlaylistResultItem.parse(section.contents),
['Community playlists']: () => YTMusicDataItems.PlaylistResultItem.parse(section.contents),
['Artists']: () => YTMusicDataItems.ArtistResultItem.parse(section.contents),
['Albums']: () => YTMusicDataItems.AlbumResultItem.parse(section.contents)
})[section_title]();
processed_data.results[section_title.replace(/ /g, '_').toLowerCase()] = section_items;
}
});
return processed_data;
}
#processSearchSuggestions() {
return YTDataItems.SearchSuggestionItem.parse(this.data[1], this.data[0]);
}
#processMusicSearchSuggestions() {
const contents = this.data.contents[0].searchSuggestionsSectionRenderer.contents;
return YTMusicDataItems.MusicSearchSuggestionItem.parse(contents);
}
#processPlaylist() {
const details = this.data.sidebar.playlistSidebarRenderer.items[0];
const metadata = {
title: this.data.metadata.playlistMetadataRenderer.title,
description: details.playlistSidebarPrimaryInfoRenderer.description.simpleText || 'N/A',
total_items: details.playlistSidebarPrimaryInfoRenderer.stats[0].runs[0].text,
last_updated: details.playlistSidebarPrimaryInfoRenderer.stats[2].runs[1].text,
views: details.playlistSidebarPrimaryInfoRenderer.stats[1].simpleText
}
const list = Utils.findNode(this.data, 'contents', 'contents', 13, false);
const items = YTDataItems.PlaylistItem.parse(list.contents);
return {
...metadata,
items
}
}
#processMusicPlaylist() {
const details = this.data.header.musicDetailHeaderRenderer;
const metadata = {
title: details?.title?.runs[0].text,
description: details?.description?.runs?.map((run) => run.text).join('') || 'N/A',
total_items: parseInt(details?.secondSubtitle?.runs[0].text.match(/\d+/g)),
duration: details?.secondSubtitle?.runs[2].text,
year: details?.subtitle?.runs[4].text
};
const contents = this.data.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents;
const playlist_content = contents[0].musicPlaylistShelfRenderer.contents;
const items = YTMusicDataItems.PlaylistItem.parse(playlist_content);
return {
...metadata,
items
}
}
/**
* Video data is parsed dynamically, so if youtube decides to add something new we won't have to change anything here.
*/
#processVideoInfo() {
const playability_status = this.data.playabilityStatus;
if (playability_status.status == 'ERROR')
throw new Error(`Could not retrieve video details: ${playability_status.status} - ${playability_status.reason}`);
const details = this.data.videoDetails;
const microformat = this.data.microformat.playerMicroformatRenderer;
const streaming_data = this.data.streamingData;
const mf_raw_data = Object.entries(microformat);
const dt_raw_data = Object.entries(details);
const processed_data = {
id: '',
title: '',
description: '',
thumbnail: [],
metadata: {}
};
// Extracts most of the metadata
mf_raw_data.forEach((entry) => {
const key = Utils.camelToSnake(entry[0]);
if (Constants.METADATA_KEYS.includes(key)) {
key == 'view_count' && (processed_data.metadata[key] = parseInt(entry[1])) ||
key == 'owner_profile_url' && (processed_data.metadata.channel_url = entry[1]) ||
key == 'owner_channel_name' && (processed_data.metadata.channel_name = entry[1]) ||
(processed_data.metadata[key] = entry[1]);
} else {
processed_data[key] = entry[1];
}
});
// Extracts extra details
dt_raw_data.forEach((entry) => {
const key = Utils.camelToSnake(entry[0]);
if (Constants.BLACKLISTED_KEYS.includes(key)) return;
if (Constants.METADATA_KEYS.includes(key)) {
key == 'view_count' && (processed_data.metadata[key] = parseInt(entry[1])) ||
(processed_data.metadata[key] = entry[1]);
} else {
key == 'short_description' && (processed_data.description = entry[1]) ||
key == 'thumbnail' && (processed_data.thumbnail = entry[1].thumbnails.slice(-1)[0]) ||
key == 'video_id' && (processed_data.id = entry[1]) ||
(processed_data[key] = entry[1]);
}
});
// Data continuation is only required for getDetails()
if (this.data.continuation) {
const primary_info_renderer = this.data.continuation.contents.twoColumnWatchNextResults
.results.results.contents.find((item) => item.videoPrimaryInfoRenderer).videoPrimaryInfoRenderer;
const secondary_info_renderer = this.data.continuation.contents.twoColumnWatchNextResults
.results.results.contents.find((item) => item.videoSecondaryInfoRenderer).videoSecondaryInfoRenderer;
const like_btn = primary_info_renderer.videoActions.menuRenderer
.topLevelButtons.find((item) => item.toggleButtonRenderer.defaultIcon.iconType == 'LIKE');
const dislike_btn = primary_info_renderer.videoActions.menuRenderer
.topLevelButtons.find((item) => item.toggleButtonRenderer.defaultIcon.iconType == 'DISLIKE');
const notification_toggle_btn = secondary_info_renderer.subscribeButton.subscribeButtonRenderer
?.notificationPreferenceButton?.subscriptionNotificationToggleButtonRenderer;
// These will always be false if logged out.
processed_data.metadata.is_liked = like_btn.toggleButtonRenderer.isToggled;
processed_data.metadata.is_disliked = dislike_btn.toggleButtonRenderer.isToggled;
processed_data.metadata.is_subscribed = secondary_info_renderer.subscribeButton.subscribeButtonRenderer?.subscribed || false;
processed_data.metadata.subscriber_count = secondary_info_renderer.owner.videoOwnerRenderer?.subscriberCountText?.simpleText || 'N/A';
processed_data.metadata.current_notification_preference = notification_toggle_btn?.states.find((state) => state.stateId == notification_toggle_btn.currentStateId)
.state.buttonRenderer.icon.iconType || 'N/A';
// Simpler version of publish_date
processed_data.metadata.publish_date_text = primary_info_renderer.dateText.simpleText;
// Only parse like count if it's enabled
if (processed_data.metadata.allow_ratings) {
processed_data.metadata.likes = {
count: parseInt(like_btn.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, '')),
short_count_text: like_btn.toggleButtonRenderer.defaultText.simpleText
};
}
processed_data.metadata.owner_badges = secondary_info_renderer.owner.videoOwnerRenderer?.badges?.map((badge) => badge.metadataBadgeRenderer.tooltip) || [];
}
streaming_data && streaming_data.adaptiveFormats &&
(processed_data.metadata.available_qualities = [...new Set(streaming_data.adaptiveFormats.filter(v => v.qualityLabel)
.map(v => v.qualityLabel).sort((a, b) => +a.replace(/\D/gi, '') - +b.replace(/\D/gi, '')))]) ||
(processed_data.metadata.available_qualities = []);
return processed_data;
}
#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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
'use strict';
const SongResultItem = require('./search/SongResultItem');
const VideoResultItem = require('./search/VideoResultItem');
const AlbumResultItem = require('./search/AlbumResultItem');
const ArtistResultItem = require('./search/ArtistResultItem');
const PlaylistResultItem = require('./search/PlaylistResultItem');
const MusicSearchSuggestionItem = require('./search/MusicSearchSuggestionItem');
const TopResultItem = require('./search/TopResultItem');
const PlaylistItem = require('./others/PlaylistItem');
module.exports = { SongResultItem, VideoResultItem, AlbumResultItem, ArtistResultItem, PlaylistResultItem, MusicSearchSuggestionItem, TopResultItem, PlaylistItem };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,33 @@
'use strict';
const SongResultItem = require('./SongResultItem');
const VideoResultItem = require('./VideoResultItem');
const AlbumResultItem = require('./AlbumResultItem');
const ArtistResultItem = require('./ArtistResultItem');
const PlaylistResultItem = require('./PlaylistResultItem');
class TopResultItem {
static parse(data) {
return data.map((item) => {
const list_item = item.musicResponsiveListItemRenderer;
const runs = list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs;
const type = runs[0].text.toLowerCase();
const parsed_item = ({
playlist: () => PlaylistResultItem.parseItem(item),
song: () => SongResultItem.parseItem(item),
video: () => VideoResultItem.parseItem(item),
artist: () => ArtistResultItem.parseItem(item),
album: () => AlbumResultItem.parseItem(item),
single: () => AlbumResultItem.parseItem(item)
}[type])();
parsed_item && (parsed_item.type = type);
return parsed_item;
}).filter((item) => item);
}
}
module.exports = TopResultItem;

View File

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

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

@@ -0,0 +1,109 @@
'use strict';
const messages = require('./messages');
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 buf = messages.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(buf).toString('base64'));
}
static encodeMessageParams(channel_id, video_id) {
const buf = messages.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');
}
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'));
}
}
module.exports = Proto;

1751
lib/proto/messages.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,44 +1,120 @@
syntax = "proto2";
package proto;
message NotificationPreferences {
string channel_id = 1;
message Preference {
int32 index = 1;
}
Preference pref_id = 2;
int32 number_0 = 3;
int32 number_1 = 4;
}
message LiveMessageParams {
message Params {
message Ids {
string channel_id = 1;
string video_id = 2;
}
Ids ids = 5;
}
Params params = 1;
int32 number_0 = 2;
int32 number_1 = 3;
}
message CreateCommentParams {
string video_id = 2;
message Params {
int32 index = 1;
}
Params params = 5;
int32 number = 10;
}
message SearchFilter {
int32 number = 1;
message Filter {
int32 param_0 = 1;
int32 param_1 = 2;
int32 param_2 = 3;
}
Filter filter = 2;
syntax = "proto2";
package proto;
message NotificationPreferences {
required string channel_id = 1;
message Preference {
required int32 index = 1;
}
Preference pref_id = 2;
optional int32 number_0 = 3;
optional int32 number_1 = 4;
}
message LiveMessageParams {
message Params {
message Ids {
required string channel_id = 1;
required string video_id = 2;
}
Ids ids = 5;
}
Params params = 1;
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 {
required string video_id = 2;
message Params {
required int32 index = 1;
}
Params params = 5;
required int32 number = 10;
}
message CreateCommentReplyParams {
required string video_id = 2;
required string comment_id = 4;
message UnknownParams {
required int32 unk_num = 1;
}
UnknownParams params = 5;
optional int32 unk_num = 10;
}
message PeformCommentActionParams {
required int32 type = 1;
optional int32 unk_num = 2;
required string comment_id = 3;
required string video_id = 5;
optional int32 unk_num_1 = 6;
optional int32 unk_num_2 = 7;
optional string unk_num_3 = 9;
optional int32 unk_num_4 = 10;
optional int32 unk_num_5 = 21;
optional string channel_id = 23;
optional int32 unk_num_6 = 30;
}
message SearchFilter {
int32 number = 1;
message Filter {
int32 param_0 = 1;
int32 param_1 = 2;
int32 param_2 = 3;
}
Filter filter = 2;
}

View File

@@ -4,11 +4,12 @@ const Utils = require('./Utils');
module.exports = {
URLS: {
YT_BASE_URL: 'https://www.youtube.com',
YT_MUSIC_URL: 'https://music.youtube.com',
YT_MOBILE_URL: 'https://m.youtube.com',
YT_WATCH_PAGE: 'https://m.youtube.com/watch',
YT_SUGGESTIONS: 'https://suggestqueries.google.com/complete/'
YT_BASE: 'https://www.youtube.com',
YT_BASE_API: 'https://www.youtube.com/youtubei/',
YT_STUDIO_BASE_API: 'https://studio.youtube.com/youtubei/',
YT_SUGGESTIONS: 'https://suggestqueries.google.com/complete/',
YT_MUSIC: 'https://music.youtube.com',
YT_MUSIC_BASE_API: 'https://music.youtube.com/youtubei/'
},
OAUTH: {
SCOPE: 'http://gdata.youtube.com https://www.googleapis.com/auth/youtube-paid-content',
@@ -23,18 +24,21 @@ 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-Encoding': 'gzip',
'Upgrade-Insecure-Requests': 1
'Accept-Language': `en-${config?.gl || 'US'}`,
'Accept-Encoding': 'gzip'
}
};
},
@@ -46,36 +50,9 @@ module.exports = {
'Referer': 'https://www.youtube.com',
'DNT': '?1'
},
INNERTUBE_REQOPTS: (info) => {
info.desktop === undefined && (info.desktop = true);
const origin = info.ytmusic && 'https://music.youtube.com' ||
info.desktop && 'https://www.youtube.com' || 'https://m.youtube.com';
let req_opts = {
params: info.params || {},
headers: {
'accept': '*/*',
'user-agent': Utils.getRandomUserAgent(info.desktop ? 'desktop' : 'mobile').userAgent,
'content-type': 'application/json',
'accept-language': 'en-US,en;q=0.9',
'x-goog-authuser': 0,
'x-goog-visitor-id': info.session.context.client.visitorData || '',
'x-youtube-client-name': info.desktop ? 1 : 2,
'x-youtube-client-version': info.session.context.client.clientVersion,
'x-youtube-chrome-connected': 'source=Chrome,mode=0,enable_account_consistency=true,supervised=false,consistency_enabled_by_default=false',
'x-origin': origin,
'origin': origin,
}
};
info.id && (req_opts.headers.referer = (info.desktop ? 'https://www.youtube.com' : 'https://m.youtube.com') + '/watch?v=' + info.id);
if (info.session.logged_in && info.desktop) {
req_opts.headers.Cookie = info.session.cookie;
req_opts.headers.authorization = info.session.cookie.length < 1 ? `Bearer ${info.session.access_token}` : info.session.auth_apisid;
}
return req_opts;
INNERTUBE_HEADERS_BASE: {
'accept': '*/*',
'content-type': 'application/json',
},
VIDEO_INFO_REQBODY: (id, sts, context) => {
return {
@@ -98,7 +75,7 @@ module.exports = {
},
YTMUSIC_VERSION: '1.20211213.00.00',
METADATA_KEYS: [
'embed', 'view_count', 'average_rating',
'embed', 'view_count', 'average_rating', 'allow_ratings',
'length_seconds', 'channel_id', 'channel_url',
'external_channel_id', 'is_live_content', 'is_family_safe',
'is_unlisted', 'is_private', 'has_ypc_metadata',
@@ -108,7 +85,7 @@ module.exports = {
],
BLACKLISTED_KEYS: [
'is_owner_viewing', 'is_unplugged_corpus',
'is_crawlable', 'allow_ratings', 'author'
'is_crawlable', 'author'
],
ACCOUNT_SETTINGS: {
// Notifications
@@ -127,6 +104,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)',
@@ -141,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;

132
lib/utils/Utils.js Normal file
View File

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

122
package-lock.json generated
View File

@@ -1,18 +1,25 @@
{
"name": "youtubei.js",
"version": "1.3.6",
"version": "1.4.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "youtubei.js",
"version": "1.3.6",
"version": "1.4.2",
"license": "MIT",
"dependencies": {
"axios": "^0.21.4",
"protons": "^2.0.3",
"flat": "^5.0.2",
"protocol-buffers-encodings": "^1.1.1",
"user-agents": "^1.0.778",
"uuid": "^8.3.2"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://ko-fi.com/luanrt"
}
},
"node_modules/axios": {
@@ -52,10 +59,18 @@
"dot-json": "bin/dot-json.js"
}
},
"node_modules/flat": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz",
"integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==",
"bin": {
"flat": "cli.js"
}
},
"node_modules/follow-redirects": {
"version": "1.14.8",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.8.tgz",
"integrity": "sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA==",
"version": "1.14.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz",
"integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w==",
"funding": [
{
"type": "individual",
@@ -76,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": {
@@ -105,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",
@@ -127,9 +122,9 @@
}
},
"node_modules/user-agents": {
"version": "1.0.943",
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.943.tgz",
"integrity": "sha512-0qfnGXlAO9jbfiFAnhKG3CIWxyfr7s5WgwVlV7ns2jnl8BwQI0rTJ4bdx0HR7FasInT9TCvsulmZgLgBQHbkZA==",
"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"
@@ -144,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": {
@@ -178,35 +173,28 @@
"underscore-keypath": "~0.0.22"
}
},
"flat": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz",
"integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ=="
},
"follow-redirects": {
"version": "1.14.8",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.8.tgz",
"integrity": "sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA=="
"version": "1.14.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz",
"integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w=="
},
"lodash.clonedeep": {
"version": "4.5.0",
"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": {
@@ -217,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",
@@ -239,9 +219,9 @@
}
},
"user-agents": {
"version": "1.0.943",
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.943.tgz",
"integrity": "sha512-0qfnGXlAO9jbfiFAnhKG3CIWxyfr7s5WgwVlV7ns2jnl8BwQI0rTJ4bdx0HR7FasInT9TCvsulmZgLgBQHbkZA==",
"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"
@@ -253,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,21 +1,28 @@
{
"name": "youtubei.js",
"version": "1.3.6",
"description": "An object-oriented library that allows you to search, get detailed info about videos, subscribe, unsubscribe, like, dislike, comment, download videos and much more!",
"version": "1.4.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)",
"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",
"types": "./typings/index.d.ts",
"directories": {
"example": "examples",
"lib": "lib"
"test": "./test",
"typings": "./typings",
"examples": "./examples",
"lib": "./lib"
},
"dependencies": {
"axios": "^0.21.4",
"protons": "^2.0.3",
"flat": "^5.0.2",
"protocol-buffers-encodings": "^1.1.1",
"user-agents": "^1.0.778",
"uuid": "^8.3.2"
},
@@ -23,6 +30,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",
@@ -32,17 +43,14 @@
"youtube-downloader",
"innertube",
"innertubeapi",
"livechat",
"unofficial",
"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"
]
}

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

@@ -2,39 +2,59 @@
const Fs = require('fs');
const Innertube = require('..');
const NToken = require('../lib/NToken');
const SigDecipher = require('../lib/Sig');
const NToken = require('../lib/deciphers/NToken');
const Signature = require('../lib/deciphers/Signature');
const Constants = require('./constants');
let failed_tests = 0;
let failed_tests_count = 0;
async function performTests() {
const youtube = await new Innertube().catch((error) => error);
assert(!(youtube instanceof Error), `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 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).transform(Constants.original_ntoken);
const n_token = new NToken(Constants.n_scramble_sc, Constants.original_ntoken).transform();
assert(n_token == Constants.expected_ntoken, `should transform n token into ${Constants.expected_ntoken}`, n_token);
const transformed_url = new SigDecipher(Constants.test_url, { sig_decipher_sc: Constants.sig_decipher_sc }).decipher();
assert(transformed_url == Constants.expected_url, `should correctly decipher signature`, transformed_url);
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 > 0)
throw new Error('Some tests have failed');
if (failed_tests_count > 0)
throw new Error(`${failed_tests_count} tests have failed`);
}
function downloadVideo(id, youtube) {
@@ -50,12 +70,12 @@ function downloadVideo(id, youtube) {
function assert(outcome, description, data) {
const pass_fail = outcome ? 'pass' : 'fail';
console.info(pass_fail, ':', description);
!outcome && (failed_tests += 1);
!outcome && (failed_tests_count += 1);
!outcome && console.error('Error: ', data);
return outcome;
}
performTests();
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;
}