Compare commits

...

23 Commits

Author SHA1 Message Date
LuanRT
9997c0d939 build (package): release v1.2.8 2021-12-18 19:14:15 -03:00
LuanRT
3dee7fc12f fix: forgot to export getVideoInfo :v 2021-12-18 12:20:17 -03:00
LuanRT
4dff129b74 chore: update tests 2021-12-18 12:17:00 -03:00
LuanRT
7e86bb15e0 refactor (OAuth): a simpler & more efficient auth system 2021-12-18 12:03:44 -03:00
LuanRT
d0de164722 chore: update examples & format code 2021-12-18 00:24:57 -03:00
LuanRT
5d165ebb61 refactor: move all internal actions to Actions.js for better maintainability 2021-12-18 00:16:47 -03:00
LuanRT
2ad19adbe4 refactor: move search request code to Actions.js for better maintainability & organization 2021-12-17 23:55:39 -03:00
LuanRT
cabbdc9f50 chore: encode search filters correctly 2021-12-17 23:08:35 -03:00
LuanRT
fe84f31432 chore: add search filter protobuf message 2021-12-17 21:12:14 -03:00
LuanRT
22c605f528 perf (OAuth): check access token validity in a more efficient way 2021-12-13 21:58:02 -03:00
LuanRT
6777b59116 feat: include available stream quality in the metadata 2021-12-13 21:38:31 -03:00
stranothus
de70d851d8 Desktop version compatible
The desktop version is sent a different resopnse by the Innertube API
and streamingData needs to be accessed from data, rather than the third
index of data and through playerResponse.
2021-12-13 15:40:44 -06:00
stranothus
e20e671d16 Include available video qualities to metadata
The playerResponse streamingData adaptiveFormats are filter to include only those which
include a qualityLabel. This array is then mapped to an array of qualityLabels and sorted
from lowest to highest quality.
2021-12-13 09:29:45 -06:00
LuanRT
d0e1140029 chore: yes, more code formatting 2021-12-09 23:24:50 -03:00
LuanRT
bf483256fe chore: remove useless comments & format code 2021-12-09 22:45:18 -03:00
LuanRT
d4c32d47e1 build (package): release v1.2.7 2021-11-24 12:14:46 -03:00
LuanRT
70feab80da fix: check if dislike count is available to avoid unexpected errors 2021-11-23 07:17:17 -03:00
LuanRT
c006f49dc1 chore: remove unnecessary param 2021-11-23 06:09:12 -03:00
LuanRT
aeff0c3fdc build (package): increment version 2021-11-19 13:50:50 -03:00
LuanRT
00d67ed417 chore (OAuth): better & simpler regular expression 2021-11-19 13:29:02 -03:00
LuanRT
78f93c7118 fix: add “g” flag so it matches all possible strings 2021-11-19 13:27:07 -03:00
LuanRT
6db3f0ad91 fix: download not possible due to visitorData being undefined 2021-11-14 12:22:56 -03:00
UnbreakCode
cf48385f72 fixed x-goog-visitor-id for downloader 2021-11-14 15:46:21 +01:00
12 changed files with 264 additions and 248 deletions

View File

@@ -650,19 +650,17 @@ async function start() {
const creds = fs.existsSync(creds_path) && JSON.parse(fs.readFileSync(creds_path).toString()) || {};
const youtube = await new Innertube();
// Only triggered when signing-in.
youtube.on('auth', (data) => {
youtube.ev.on('auth', (data) => {
if (data.status === 'AUTHORIZATION_PENDING') {
console.info(`Hello!\nOn your phone or computer, go to ${data.verification_url} and enter the code ${data.code}`);
} else if (data.status === 'SUCCESS') {
fs.writeFileSync(creds_path, JSON.stringify({ access_token: data.access_token, refresh_token: data.refresh_token }));
fs.writeFileSync(creds_path, JSON.stringify(data.credentials));
console.info('Successfully signed-in, enjoy!');
}
});
// Triggered whenever the access token is refreshed.
youtube.on('update-credentials', (data) => {
fs.writeFileSync(creds_path, JSON.stringify({ access_token: data.access_token, refresh_token: data.refresh_token }));
youtube.ev.on('update-credentials', (data) => {
fs.writeFileSync(creds_path, JSON.stringify(data.credentials));
console.info('Credentials updated!', data);
});

View File

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

View File

@@ -6,8 +6,9 @@ const Utils = require('./Utils');
const Constants = require('./Constants');
async function engage(session, engagement_type, args = {}) {
if (!session.logged_in) throw new Error('You are not logged in');
let data = {};
if (!session.logged_in) throw new Error('You are not signed-in');
let data;
switch (engagement_type) {
case 'like/like':
case 'like/dislike':
@@ -36,8 +37,11 @@ async function engage(session, engagement_type, args = {}) {
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);
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
@@ -45,9 +49,10 @@ async function engage(session, engagement_type, args = {}) {
}
async function browse(session, action_type) {
if (!session.logged_in) throw new Error('You are not logged in');
if (!session.logged_in) throw new Error('You are not signed-in');
let data;
switch (action_type) {
switch (action_type) { // TODO: Handle more actions
case 'subscriptions_feed':
data = {
context: session.context,
@@ -57,8 +62,29 @@ async function browse(session, action_type) {
default:
}
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/browse${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error);
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/browse${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
data: response.data
};
}
async function search(session, args = {}) {
if (!args.query) throw new Error('No query was provided');
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/search${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify({
context: session.context,
params: Utils.encodeFilter(args.options.period, args.options.duration, args.options.order),
query: args.query
}), 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,
@@ -67,8 +93,10 @@ async function browse(session, action_type) {
}
async function notifications(session, action_type, args = {}) {
if (!session.logged_in) throw new Error('You are not logged in');
if (!session.logged_in) throw new Error('You are not signed-in');
let data;
switch (action_type) {
case 'modify_channel_preference':
let pref_types = { PERSONALIZED: 1, ALL: 2, NONE: 3 };
@@ -91,9 +119,11 @@ async function notifications(session, action_type, args = {}) {
default:
}
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/notification/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error);
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/notification/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
if (action_type === 'modify_channel_preference') return { success: true, status_code: response.status };
return {
success: true,
status_code: response.status,
@@ -128,8 +158,10 @@ async function livechat(session, action_type, args = {}) {
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);
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, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
@@ -137,6 +169,17 @@ async function livechat(session, action_type, args = {}) {
};
}
async function getVideoInfo(session, args = {}) {
let response;
!args.is_desktop && (response = await Axios.get(`${Constants.URLS.YT_WATCH_PAGE}?v=${args.id}&t=8s&pbj=1`, Constants.INNERTUBE_REQOPTS({ session, id: args.id, desktop: false })).catch((error) => error)) ||
(response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/player${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(Constants.VIDEO_INFO_REQBODY(args.id, session.sts, session.context)), Constants.INNERTUBE_REQOPTS({ session, id: args.id, desktop: true })).catch((error) => error));
if (response instanceof Error) throw new Error(`Could not get video info: ${response.message}`);
return response.data;
}
async function getContinuation(session, info = {}) {
let data = { context: session.context };
info.continuation_token && (data.continuation = info.continuation_token);
@@ -153,8 +196,10 @@ async function getContinuation(session, info = {}) {
data.captionsRequested = false;
}
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/next${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error);
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/next${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_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,
@@ -162,4 +207,4 @@ async function getContinuation(session, info = {}) {
};
}
module.exports = { engage, browse, notifications, livechat, getContinuation };
module.exports = { engage, browse, search, notifications, livechat, getVideoInfo, getContinuation };

View File

@@ -54,7 +54,7 @@ module.exports = {
'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-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',
@@ -113,7 +113,8 @@ module.exports = {
formatNTransformData: (data) => {
return data
.replace(/function\(d,e\)/g, '"function(d,e)').replace(/function\(d\)/g, '"function(d)')
.replace(/function\(\)/, '"function()').replace(/function\(d,e,f\)/g, '"function(d,e,f)')
.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])}');
@@ -140,15 +141,19 @@ module.exports = {
metadata.publish_date = data.microformat.playerMicroformatRenderer.publishDate || 'N/A';
metadata.upload_date = data.microformat.playerMicroformatRenderer.uploadDate || 'N/A';
metadata.keywords = data.videoDetails.keywords || [];
metadata.available_qualities = [...new Set(data.streamingData.adaptiveFormats.filter(v => v.qualityLabel).map(v => v.qualityLabel).sort((a, b) => +a.replace(/\D/gi, '') - +b.replace(/\D/gi, '')))];
video_details.id = data.videoDetails.videoId;
video_details.title = data.videoDetails.title;
video_details.description = data.videoDetails.shortDescription;
video_details.thumbnail = data.videoDetails.thumbnail.thumbnails.slice(-1)[0];
video_details.metadata = metadata;
} else {
const is_dislike_available = data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1].slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[1].slimMetadataToggleButtonRenderer.button.toggleButtonRenderer.defaultText.accessibility && true || false;
metadata.embed = data[2].playerResponse.microformat.playerMicroformatRenderer.embed;
metadata.likes = parseInt(data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1].slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[0].slimMetadataToggleButtonRenderer.button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, ''));
metadata.dislikes = parseInt(data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1].slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[1].slimMetadataToggleButtonRenderer.button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, ''));
metadata.dislikes = is_dislike_available && parseInt(data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1].slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[1].slimMetadataToggleButtonRenderer.button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, '')) || 0;
metadata.view_count = parseInt(data[2].playerResponse.videoDetails.viewCount);
metadata.average_rating = data[2].playerResponse.videoDetails.averageRating;
metadata.length_seconds = data[2].playerResponse.microformat.playerMicroformatRenderer.lengthSeconds;
@@ -165,12 +170,14 @@ module.exports = {
metadata.publish_date = data[2].playerResponse.microformat.playerMicroformatRenderer.publishDate;
metadata.upload_date = data[2].playerResponse.microformat.playerMicroformatRenderer.uploadDate;
metadata.keywords = data[2].playerResponse.videoDetails.keywords;
metadata.available_qualities = [...new Set(data[2].playerResponse.streamingData.adaptiveFormats.filter(v => v.qualityLabel).map(v => v.qualityLabel).sort((a, b) => +a.replace(/\D/gi, '') - +b.replace(/\D/gi, '')))];
video_details.id = data[2].playerResponse.videoDetails.videoId;
video_details.title = data[2].playerResponse.videoDetails.title;
video_details.description = data[2].playerResponse.videoDetails.shortDescription;
video_details.thumbnail = data[2].playerResponse.videoDetails.thumbnail.thumbnails.slice(-1)[0];
// Functions
// Placeholders for functions
video_details.like = () => {};
video_details.dislike = () => {};
video_details.removeLike = () => {};
@@ -185,81 +192,5 @@ module.exports = {
video_details.metadata = metadata;
}
return video_details;
},
filters: (order) => {
return (({
'any,any,relevance': 'EgIQAQ%3D%3D',
'hour,any,relevance': 'EgIIAQ%3D%3D',
'day,any,relevance': 'EgQIAhAB',
'week,any,relevance': 'EgQIAxAB',
'month,any,relevance': 'EgQIBBAB',
'year,any,relevance': 'EgQIBRAB',
'any,short,relevance': 'EgQQARgB',
'hour,short,relevance': 'EgYIARABGAE%3D',
'day,short,relevance': 'EgYIAhABGAE%3D',
'week,short,relevance': 'EgYIAxABGAE%3D',
'month,short,relevance': 'EgYIBBABGAE%3D',
'year,short,relevance': 'EgYIBRABGAE%3D',
'any,long,relevance': 'EgQQARgC',
'hour,long,relevance': 'EgYIARABGAI%3D',
'day,long,relevance': 'EgYIAhABGAI%3D',
'week,long,relevance': 'EgYIAxABGAI%3D',
'month,long,relevance': 'EgYIBBABGAI%3D',
'year,long,relevance': 'EgYIBRABGAI%3D',
'any,any,age': 'CAISAhAB',
'hour,any,age': 'CAISBAgBEAE%3D',
'day,any,age': 'CAISBAgCEAE%3D',
'week,any,age': 'CAISBAgDEAE%3D',
'month,any,age': 'CAISBAgEEAE%3D',
'year,any,age': 'CAISBAgFEAE%3D',
'any,short,age': 'CAISBBABGAE%3D',
'hour,short,age': 'CAISBggBEAEYAQ%3D%3D',
'day,short,age': 'CAISBggCEAEYAQ%3D%3D',
'week,short,age': 'CAISBggDEAEYAQ%3D%3D',
'month,short,age': 'CAISBggEEAEYAQ%3D%3D',
'year,short,age': 'CAISBggFEAEYAQ%3D%3D',
'any,long,age': 'CAISBBABGAI%3D',
'hour,long,age': 'CAISBggBEAEYAg%3D%3D',
'day,long,age': 'CAISBggCEAEYAg%3D%3D',
'week,long,age': 'CAISBggDEAEYAg%3D%3D',
'month,long,age': 'CAISBggEEAEYAg%3D%3D',
'year,long,age': 'CAISBggFEAEYAg%3D%3D',
'any,any,views': 'CAMSAhAB',
'hour,any,views': 'CAMSBAgBEAE%3D',
'day,any,views': 'CAMSBAgCEAE%3D',
'week,any,views': 'CAMSBAgDEAE%3D',
'month,any,views': 'CAMSBAgEEAE%3D',
'year,any,views': 'CAMSBAgFEAE%3D',
'any,short,views': 'CAMSBBABGAE%3D',
'hour,short,views': 'CAMSBggBEAEYAQ%3D%3D',
'day,short,views': 'CAMSBggCEAEYAQ%3D%3D',
'week,short,views': 'CAMSBggDEAEYAQ%3D%3D',
'month,short,views': 'CAMSBggEEAEYAQ%3D%3D',
'year,short,views': 'CAMSBggFEAEYAQ%3D%3D',
'any,long,views': 'CAMSBBABGAI%3D',
'hour,long,views': 'CAMSBggBEAEYAg%3D%3D',
'day,long,views': 'CAMSBggCEAEYAg%3D%3D',
'week,long,views': 'CAMSBggDEAEYAg%3D%3D',
'month,long,views': 'CAMSBggEEAEYAg%3D%3D',
'year,long,views': 'CAMSBggFEAEYAg%3D%3D',
'any,any,rating': 'CAESAhAB',
'hour,any,rating': 'CAESBAgBEAE%3D',
'day,any,rating': 'CAESBAgCEAE%3D',
'week,any,rating': 'CAESBAgDEAE%3D',
'month,any,rating': 'CAESBAgEEAE%3D',
'year,any,rating': 'CAESBAgFEAE%3D',
'any,short,rating': 'CAESBBABGAE%3D',
'hour,short,rating': 'CAESBggBEAEYAQ%3D%3D',
'day,short,rating': 'CAESBggCEAEYAQ%3D%3D',
'week,short,rating': 'CAESBggDEAEYAQ%3D%3D',
'month,short,rating': 'CAESBggEEAEYAQ%3D%3D',
'year,short,rating': 'CAESBggFEAEYAQ%3D%3D',
'any,long,rating': 'CAESBBABGAI%3D',
'hour,long,rating': 'CAESBggBEAEYAg%3D%3D',
'day,long,rating': 'CAESBggCEAEYAg%3D%3D',
'week,long,rating': 'CAESBggDEAEYAg%3D%3D',
'month,long,rating': 'CAESBggEEAEYAg%3D%3D',
'year,long,rating': 'CAESBggFEAEYAg%3D%3D'
})[order] || 'EgIQAQ%3D%3D');
}
};

View File

@@ -14,9 +14,8 @@ const EventEmitter = require('events');
const TimeToSeconds = require('time-to-seconds');
const CancelToken = Axios.CancelToken;
class Innertube extends EventEmitter {
class Innertube {
constructor(cookie) {
super();
this.cookie = cookie || '';
this.retry_count = 0;
return this.init();
@@ -46,6 +45,8 @@ class Innertube extends EventEmitter {
this.auth_apisid = Utils.getStringBetweenStrings(this.cookie, 'PAPISID=', ';');
this.auth_apisid = Utils.generateSidAuth(this.auth_apisid);
}
this.ev = new EventEmitter();
} else {
this.retry_count += 1;
if (this.retry_count >= 10) throw new Error('Could not retrieve Innertube data');
@@ -59,40 +60,43 @@ class Innertube extends EventEmitter {
return this;
}
signIn(credentials = {}) {
signIn(auth_info = {}) {
return new Promise(async (resolve, reject) => {
const oauth = new OAuth(credentials);
if (credentials.access_token && credentials.refresh_token) {
let token_validity = await oauth.checkTokenValidity(credentials.access_token, this);
if (token_validity === 'VALID') {
this.access_token = credentials.access_token;
this.refresh_token = credentials.refresh_token;
this.logged_in = true;
resolve();
} else {
oauth.refreshAccessToken(credentials.refresh_token);
oauth.on('refresh-token', (data) => {
this.access_token = data.access_token;
this.refresh_token = credentials.refresh_token;
this.logged_in = true;
this.emit('update-credentials', {
access_token: data.access_token,
refresh_token: credentials.refresh_token,
status: data.status
});
resolve();
const oauth = new OAuth(auth_info);
if (auth_info.access_token) {
const is_valid = await oauth.isTokenValid(auth_info.expires);
if (!is_valid) {
const new_tokens = await oauth.refreshAccessToken(auth_info.refresh_token);
auth_info.refresh_token = new_tokens.credentials.refresh_token;
auth_info.access_token = new_tokens.credentials.access_token;
this.ev.emit('update-credentials', {
credentials: new_tokens.credentials,
status: new_tokens.status
});
}
this.access_token = auth_info.access_token;
this.refresh_token = auth_info.refresh_token;
this.logged_in = true;
resolve();
} else {
oauth.on('auth', (data) => {
if (data.status === 'SUCCESS') {
this.emit('auth', data);
this.access_token = data.access_token;
this.refresh_token = data.refresh_token;
this.access_token = data.credentials.access_token;
this.refresh_token = data.credentials.refresh_token;
this.logged_in = true;
this.ev.emit('auth', {
credentials: data.credentials,
status: data.status
});
resolve();
} else {
this.emit('auth', data);
this.ev.emit('auth', data);
}
});
}
@@ -100,21 +104,19 @@ class Innertube extends EventEmitter {
}
async search(query, options = { period: 'any', order: 'relevance', duration: 'any' }) {
if (!query) throw new Error('No query was provided');
const response = await Actions.search(this, { query, options });
if (!response.success) throw new Error(`Could not search on YouTube: ${response.message}`);
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/search${this.logged_in && this.cookie.length < 1 ? '' : `?key=${this.key}`}`, JSON.stringify({ context: this.context, params: Constants.filters(options.period + ',' + options.duration + ',' + options.order), query }), Constants.INNERTUBE_REQOPTS({ session: this })).catch((error) => error);
if (response instanceof Error) throw new Error(`Could not search on YouTube: ${response.message}`);
const content = response.data.contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents[0].itemSectionRenderer.contents;
const search = {};
let content = response.data.contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents[0].itemSectionRenderer.contents;
let search_response = {};
search_response.search_metadata = {};
search_response.search_metadata.query = content[0].showingResultsForRenderer ? content[0].showingResultsForRenderer.originalQuery.simpleText : query;
search_response.search_metadata.corrected_query = content[0].showingResultsForRenderer ? content[0].showingResultsForRenderer.correctedQueryEndpoint.searchEndpoint.query : query;
search_response.search_metadata.estimated_results = parseInt(response.data.estimatedResults);
search_response.videos = content.map((data) => {
search.search_metadata = {};
search.search_metadata.query = content[0].showingResultsForRenderer ? content[0].showingResultsForRenderer.originalQuery.simpleText : query;
search.search_metadata.corrected_query = content[0].showingResultsForRenderer ? content[0].showingResultsForRenderer.correctedQueryEndpoint.searchEndpoint.query : query;
search.search_metadata.estimated_results = parseInt(response.data.estimatedResults);
search.videos = content.map((data) => {
if (!data.videoRenderer) return;
let video = data.videoRenderer;
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',
@@ -140,13 +142,13 @@ class Innertube extends EventEmitter {
}
};
}).filter((video_block) => video_block !== undefined);
return search_response;
return search;
}
async getDetails(id) {
if (!id) return { error: 'Missing video id' };
if (!id) throw new Error('You must provide a video id');
const data = await this.requestVideoInfo(id, false);
const data = await Actions.getVideoInfo(this, { id, is_desktop: false });
const video_data = Constants.formatVideoData(data, this, false);
if (video_data.metadata.is_live_content) {
@@ -283,14 +285,6 @@ class Innertube extends EventEmitter {
return response.data.unseenCount;
}
async requestVideoInfo(id, desktop) {
let response;
!desktop && (response = await Axios.get(`${Constants.URLS.YT_WATCH_PAGE}?v=${id}&t=8s&pbj=1`, Constants.INNERTUBE_REQOPTS({ session: this, id, desktop: false })).catch((error) => error)) ||
(response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/player${this.logged_in && this.cookie.length < 1 ? '' : `?key=${this.key}`}`, JSON.stringify(Constants.VIDEO_INFO_REQBODY(id, this.sts, this.context)), Constants.INNERTUBE_REQOPTS({ session: this, id, desktop: true })).catch((error) => error));
if (response instanceof Error) throw new Error('Could not retrieve watch page info: ' + response.message);
return response.data;
}
download(id, options = {}) {
if (!id) throw new Error('Missing video id');
@@ -302,7 +296,7 @@ class Innertube extends EventEmitter {
let cancelled = false;
const stream = new Stream.PassThrough();
this.requestVideoInfo(id, true).then(async (video_data) => {
Actions.getVideoInfo(this, { id, is_desktop: true }).then(async (video_data) => {
let formats = [];
if (video_data.playabilityStatus.status === 'LOGIN_REQUIRED') return stream.emit('error', { message: 'You must login to download age-restricted videos.', error_type: 'LOGIN_REQUIRED', playability_status: video_data.playabilityStatus.status });
@@ -387,6 +381,7 @@ class Innertube extends EventEmitter {
}
let downloaded_size = 0;
response.data.on('data', (chunk) => {
downloaded_size += chunk.length;
let size = (response.headers['content-length'] / 1024 / 1024).toFixed(2);
@@ -395,17 +390,14 @@ class Innertube extends EventEmitter {
});
response.data.on('error', (err) => {
if (cancelled) {
stream.emit('error', { message: 'The download was cancelled.', type: 'DOWNLOAD_CANCELLED' });
} else {
cancelled && stream.emit('error', { message: 'The download was cancelled.', type: 'DOWNLOAD_CANCELLED' }) ||
stream.emit('error', { message: err.message, type: 'DOWNLOAD_ABORTED' });
}
});
response.data.pipe(stream, { end: true });
} else {
const chunk_size = 1048576 * 10; // 10MB
let chunk_start = (options.range && options.range.start || 0);
let chunk_end = (options.range && options.range.end || chunk_size);
let downloaded_size = 0;

View File

@@ -14,7 +14,7 @@ class NToken {
let n_token = n.split('');
try {
let transformations = this.getTransformationData(this.raw_code);
let transformations = this.getTransformationData();
transformations = transformations.map((el) => {
if (el != null && typeof el != 'number') {
const is_reverse_base64 = el.includes('case 65:');

View File

@@ -4,38 +4,31 @@ const Axios = require('axios');
const Utils = require('./Utils');
const Constants = require('./Constants');
const EventEmitter = require('events');
const Uuid = require("uuid");
const Uuid = require('uuid');
class OAuth extends EventEmitter {
constructor(creds) {
constructor(auth_info) {
super();
// Default interval between requests when waiting for authorization.
this.refresh_interval = 5;
// OAuth URLs:
this.oauth_code_url = `${Constants.URLS.YT_BASE_URL}/o/oauth2/device/code`;
this.oauth_token_url = `${Constants.URLS.YT_BASE_URL}/o/oauth2/token`;
// Used to check whether an access token is valid or not.
this.guide_url = `${Constants.URLS.YT_BASE_URL}/youtubei/v1/guide`;
// These are always the same, so we shouldn't have any problems for now.
this.model_name = Constants.OAUTH.MODEL_NAME;
this.grant_type = Constants.OAUTH.GRANT_TYPE;
this.scope = Constants.OAUTH.SCOPE;
// Script that contains important information such as client id and client secret.
this.auth_script_regex = /<script id=\"base-js\" src=\"(.*?)\" nonce=".*?"><\/script>/;
this.identity_regex = /var .+?=\"(?<id>.+?)\",.+?=\"(?<secret>.+?)\"/;
// Used to find the credentials inside the script.
this.identity_regex = /var .+?=\"(?<id>.+?)\",.?=\"(?<secret>.+?)\"/;
if (creds.access_token != undefined && creds.refresh_token != undefined) return;
if (auth_info.access_token) return;
this.requestAuthCode();
}
async requestAuthCode() {
const identity = await this.getClientIdentity();
this.client_id = identity.id;
this.client_secret = identity.secret;
@@ -47,6 +40,7 @@ class OAuth extends EventEmitter {
};
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.',
@@ -66,22 +60,6 @@ class OAuth extends EventEmitter {
this.waitForAuth(response.data.device_code);
}
async getClientIdentity() {
// The first request is made to get the auth script url, hard-coding it isn't viable as it changes overtime.
const yttv_response = await Axios.get(`${Constants.URLS.YT_BASE_URL}/tv`, Constants.OAUTH.HEADERS).catch((error) => error);
if (yttv_response instanceof Error) throw new Error(`Could not extract client identify: ${yttv_response.message}`);
// Here we get the script and extract the necessary data to proceed with the auth flow.
const url_body = this.auth_script_regex.exec(yttv_response.data)[1];
const script_url = `${Constants.URLS.YT_BASE_URL}/${url_body}`;
const response = await Axios.get(script_url, Constants.DEFAULT_HEADERS).catch((error) => error);
if (response instanceof Error) throw new Error(`Could not extract client identify: ${response.message}`);
const identity_function = Utils.getStringBetweenStrings(response.data, 'YTLR_STORAGE_NAMESPACE",', 'reloadAppFlushLogsMaxTimeoutMs:');
const client_identity = identity_function.replace(/\n/g, '').match(this.identity_regex);
return client_identity.groups;
}
waitForAuth(device_code) {
const data = {
client_id: this.client_id,
@@ -94,7 +72,7 @@ class OAuth extends EventEmitter {
const response = await Axios.post(this.oauth_token_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
if (response instanceof Error)
return this.emit('auth', {
error: 'Could not get auth token.',
error: 'Could not get authentication token.',
status: 'FAILED'
});
@@ -106,7 +84,7 @@ class OAuth extends EventEmitter {
break;
case 'access_denied':
this.emit('auth', {
error: 'The access was denied.',
error: 'Access was denied.',
status: 'ACCESS_DENIED'
});
break;
@@ -120,12 +98,15 @@ class OAuth extends EventEmitter {
default:
}
} else {
const expiration_date = new Date(new Date().getTime() + response.data.expires_in * 1000);
this.emit('auth', {
access_token: response.data.access_token,
refresh_token: response.data.refresh_token,
credentials: {
access_token: response.data.access_token,
refresh_token: response.data.refresh_token,
expires: expiration_date,
},
token_type: response.data.token_type,
expires: response.data.expires_in,
scope: response.data.scope,
status: 'SUCCESS'
});
}
@@ -143,28 +124,57 @@ class OAuth extends EventEmitter {
};
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('refresh-token', {
if (response instanceof Error) {
this.emit('auth', {
error: 'Could not refresh access token.',
status: 'FAILED'
});
this.emit('refresh-token', {
access_token: response.data.access_token,
return {
credentials: {
access_token: this.auth_info.access_token,
refresh_token: this.auth_info.refresh_token,
expires: this.auth_info.expires
},
status: 'FAILED'
};
}
const expiration_date = new Date(new Date().getTime() + response.data.expires_in * 1000);
return {
credentials: {
refresh_token: refresh_token,
access_token: response.data.access_token,
expires: expiration_date
},
token_type: response.data.token_type,
expires: response.data.expires_in,
scope: response.data.scope,
status: 'SUCCESS'
});
};
}
async checkTokenValidity(access_token, session) {
let headers = Constants.INNERTUBE_REQOPTS({ session }).headers;
headers.authorization = `Bearer ${access_token}`;
async isTokenValid(expiration_date) {
const timestamp = new Date(expiration_date).getTime();
const is_valid = new Date().getTime() < timestamp;
return is_valid;
}
const response = await Axios.post(this.guide_url, JSON.stringify({ context: session.context }), { headers }).catch((error) => error);
if (response instanceof Error) return 'INVALID';
return 'VALID';
async getClientIdentity() {
// The first request is made to get the auth script url, hard-coding it isn't viable as it changes overtime.
const yttv_response = await Axios.get(`${Constants.URLS.YT_BASE_URL}/tv`, Constants.OAUTH.HEADERS).catch((error) => error);
if (yttv_response instanceof Error) throw new Error(`Could not extract client identity: ${yttv_response.message}`);
// Here we get the script and extract the necessary data to proceed with the auth flow.
const url_body = this.auth_script_regex.exec(yttv_response.data)[1];
const script_url = `${Constants.URLS.YT_BASE_URL}/${url_body}`;
const response = await Axios.get(script_url, Constants.DEFAULT_HEADERS).catch((error) => error);
if (response instanceof Error) throw new Error(`Could not extract client identity: ${response.message}`);
const identity_function = Utils.getStringBetweenStrings(response.data, 'setQuery("");', '{useGaiaSandbox:');
const client_identity = identity_function.replace(/\n/g, '').match(this.identity_regex);
return client_identity.groups;
}
}

View File

@@ -83,4 +83,23 @@ function generateCommentParams(video_id) {
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
module.exports = { getRandomUserAgent, generateSidAuth, getStringBetweenStrings, generateMessageParams, generateCommentParams, encodeNotificationPref };
function encodeFilter(period, duration, order) {
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
const periods = { 'any': null, 'hour': 1, 'day': 2, 'week': 3, 'month': 4, 'year': 5 };
const durations = { 'any': null, 'short': 1, 'long': 2 };
const orders = { 'relevance': null, 'rating': 1, 'age': 2, 'views': 3 };
const search_filter_buff = youtube_proto.SearchFilter.encode({
number: orders[order],
filter: {
param_0: periods[period],
param_1: (period == 'hour' && order == 'relevance') ? null : 1,
param_2: durations[duration]
}
});
return encodeURIComponent(Buffer.from(search_filter_buff).toString('base64'));
}
module.exports = { getRandomUserAgent, generateSidAuth, getStringBetweenStrings, generateMessageParams, generateCommentParams, encodeNotificationPref, encodeFilter };

View File

@@ -31,4 +31,14 @@ message CreateCommentParams {
}
Params params = 5;
int32 number = 10;
}
message SearchFilter {
int32 number = 1;
message Filter {
int32 param_0 = 1;
int32 param_1 = 2;
int32 param_2 = 3;
}
Filter filter = 2;
}

52
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "youtubei.js",
"version": "1.2.5",
"version": "1.2.8",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "youtubei.js",
"version": "1.2.5",
"version": "1.2.8",
"license": "MIT",
"dependencies": {
"axios": "^0.21.4",
@@ -54,9 +54,9 @@
}
},
"node_modules/follow-redirects": {
"version": "1.14.5",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.5.tgz",
"integrity": "sha512-wtphSXy7d4/OR+MvIFbCVBDzZ5520qV8XfPklSN5QtxuMUJZ+b0Wnst1e1lCDocfzuCkHqj8k0FpZqO+UIaKNA==",
"version": "1.14.6",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.6.tgz",
"integrity": "sha512-fhUl5EwSJbbl8AR+uYL2KQDxLkdSjZGR36xy46AO7cOMTrCMON6Sa28FmAnC2tRTDbd/Uuzz3aJBv7EBN7JH8A==",
"funding": [
{
"type": "individual",
@@ -78,9 +78,9 @@
"integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8="
},
"node_modules/multiformats": {
"version": "9.4.10",
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.4.10.tgz",
"integrity": "sha512-BwWGvgqB/5J/cnWaOA0sXzJ+UGl+kyFAw3Sw1L6TN4oad34C9OpW+GCpYTYPDp4pUaXDC1EjvB3yv9Iodo1EhA=="
"version": "9.5.4",
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.5.4.tgz",
"integrity": "sha512-MFT8e8BOLX7OZKfSBGm13FwYvJVI6MEcZ7hujUCpyJwvYyrC1anul50A0Ee74GdeJ77aYTO6YU1vO+oF8NqSIw=="
},
"node_modules/protocol-buffers-schema": {
"version": "3.6.0",
@@ -124,9 +124,9 @@
}
},
"node_modules/underscore": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.1.tgz",
"integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g=="
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.2.tgz",
"integrity": "sha512-ekY1NhRzq0B08g4bGuX4wd2jZx5GnKz6mKSqFL4nqBlfyMGiG10gDFhDTMEfYmDL6Jy0FUIZp7wiRB+0BP7J2g=="
},
"node_modules/underscore-keypath": {
"version": "0.0.22",
@@ -137,9 +137,9 @@
}
},
"node_modules/user-agents": {
"version": "1.0.833",
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.833.tgz",
"integrity": "sha512-Vx6fikPUwMSR2emhgpVdmZz/OkiHS7f+n1hfW11US1dnYg7irTmBIP+or4R7GrG11ZU6J2hX3hx4iSriPPh67Q==",
"version": "1.0.869",
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.869.tgz",
"integrity": "sha512-fjHZWYMi0CBF8ni/TIcCUFB7GiTTT+UcCAxT9/kNbPd/d5PR9VZ0wOeWZ5McbdJ42PRdFWc1ZZUMJIvpNSsewg==",
"dependencies": {
"dot-json": "^1.2.2",
"lodash.clonedeep": "^4.5.0"
@@ -189,9 +189,9 @@
}
},
"follow-redirects": {
"version": "1.14.5",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.5.tgz",
"integrity": "sha512-wtphSXy7d4/OR+MvIFbCVBDzZ5520qV8XfPklSN5QtxuMUJZ+b0Wnst1e1lCDocfzuCkHqj8k0FpZqO+UIaKNA=="
"version": "1.14.6",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.6.tgz",
"integrity": "sha512-fhUl5EwSJbbl8AR+uYL2KQDxLkdSjZGR36xy46AO7cOMTrCMON6Sa28FmAnC2tRTDbd/Uuzz3aJBv7EBN7JH8A=="
},
"lodash.clonedeep": {
"version": "4.5.0",
@@ -199,9 +199,9 @@
"integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8="
},
"multiformats": {
"version": "9.4.10",
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.4.10.tgz",
"integrity": "sha512-BwWGvgqB/5J/cnWaOA0sXzJ+UGl+kyFAw3Sw1L6TN4oad34C9OpW+GCpYTYPDp4pUaXDC1EjvB3yv9Iodo1EhA=="
"version": "9.5.4",
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.5.4.tgz",
"integrity": "sha512-MFT8e8BOLX7OZKfSBGm13FwYvJVI6MEcZ7hujUCpyJwvYyrC1anul50A0Ee74GdeJ77aYTO6YU1vO+oF8NqSIw=="
},
"protocol-buffers-schema": {
"version": "3.6.0",
@@ -241,9 +241,9 @@
}
},
"underscore": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.1.tgz",
"integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g=="
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.2.tgz",
"integrity": "sha512-ekY1NhRzq0B08g4bGuX4wd2jZx5GnKz6mKSqFL4nqBlfyMGiG10gDFhDTMEfYmDL6Jy0FUIZp7wiRB+0BP7J2g=="
},
"underscore-keypath": {
"version": "0.0.22",
@@ -254,9 +254,9 @@
}
},
"user-agents": {
"version": "1.0.833",
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.833.tgz",
"integrity": "sha512-Vx6fikPUwMSR2emhgpVdmZz/OkiHS7f+n1hfW11US1dnYg7irTmBIP+or4R7GrG11ZU6J2hX3hx4iSriPPh67Q==",
"version": "1.0.869",
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.869.tgz",
"integrity": "sha512-fjHZWYMi0CBF8ni/TIcCUFB7GiTTT+UcCAxT9/kNbPd/d5PR9VZ0wOeWZ5McbdJ42PRdFWc1ZZUMJIvpNSsewg==",
"requires": {
"dot-json": "^1.2.2",
"lodash.clonedeep": "^4.5.0"

View File

@@ -1,6 +1,6 @@
{
"name": "youtubei.js",
"version": "1.2.5",
"version": "1.2.8",
"description": "An object-oriented library that allows you to search, get detailed info about videos, subscribe, unsubscribe, like, dislike, comment, download videos and much more!",
"main": "index.js",
"scripts": {

View File

@@ -10,27 +10,27 @@ let failed_tests = 0;
async function performTests() {
const youtube = await new Innertube().catch((error) => error);
assert(youtube instanceof Error ? false : true, `should retrieve Innertube configuration data`);
assert(youtube instanceof Error ? false : true, `should retrieve Innertube configuration data`, youtube);
if (!(youtube instanceof Error)) {
const search = await youtube.search('Carl Sagan - Documentary').catch((error) => error);
assert((search instanceof Error ? false : true) && search.videos.length >= 1, `should search videos`);
assert((search instanceof Error ? false : true) && search.videos.length >= 1, `should search videos`, search);
const details = await youtube.getDetails(Constants.test_video_id).catch((error) => error);
assert(details instanceof Error ? false : true, `should retrieve details for ${Constants.test_video_id}`);
assert(details instanceof Error ? false : true, `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 ? false : true, `should retrieve comments for ${Constants.test_video_id}`);
assert(comments instanceof Error ? false : true, `should retrieve comments for ${Constants.test_video_id}`, comments);
const video = await downloadVideo(Constants.test_video_id_1, youtube).catch((error) => error);
assert(video instanceof Error ? false : true, `should download video (${Constants.test_video_id_1})`);
assert(video instanceof Error ? false : true, `should download video (${Constants.test_video_id_1})`, video);
}
const n_token = new NToken(Constants.n_scramble_sc).transform(Constants.original_ntoken);
assert(n_token == Constants.expected_ntoken, `should transform n token into ${Constants.expected_ntoken}`);
assert(n_token == Constants.expected_ntoken, `should transform n token into ${Constants.expected_ntoken}`, n_token);
const transformed_url = new SigDecipher(Constants.test_url, Constants.client_version, { sig_decipher_sc: Constants.sig_decipher_sc, ntoken_sc: Constants.n_scramble_sc }).decipher();
assert(transformed_url == Constants.expected_url, `should correctly decipher signature`);
assert(transformed_url == Constants.expected_url, `should correctly decipher signature`, transformed_url);
if (failed_tests > 0)
throw new Error('Some tests have failed');
@@ -43,14 +43,14 @@ function downloadVideo(id, youtube) {
stream.pipe(Fs.createWriteStream(`./${id}.mp4`));
stream.on('end', () => Fs.existsSync(`./${id}.mp4`) && got_video_info && resolve() || reject());
stream.on('info', () => got_video_info = true);
stream.on('error', () => reject());
stream.on('error', (err) => reject(err));
});
}
function assert(outcome, description) {
function assert(outcome, description, data) {
const pass_fail = outcome ? 'pass' : 'fail';
!outcome && (failed_tests += 1);
console.info(pass_fail, ':', description);
console.info(pass_fail, ':', description, !outcome && `\nError: ${data}` || '');
return outcome;
};