mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-06-13 09:32:12 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7230a2d927 | ||
|
|
924693349c | ||
|
|
1ab302319d | ||
|
|
bbc1d0135b | ||
|
|
9c1e34c9ab | ||
|
|
c5eea2b4ff | ||
|
|
60130f4d0f | ||
|
|
5090c572d5 | ||
|
|
c9c72d0f31 | ||
|
|
7635f49191 | ||
|
|
c932e65dad | ||
|
|
23717aab11 | ||
|
|
85df28a7fb | ||
|
|
9f4970b3ee | ||
|
|
82bbc715ff |
3
.github/ISSUE_TEMPLATE/ISSUE.md
vendored
3
.github/ISSUE_TEMPLATE/ISSUE.md
vendored
@@ -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
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -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.
|
||||
@@ -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();
|
||||
731
lib/Innertube.js
731
lib/Innertube.js
@@ -2,17 +2,20 @@
|
||||
|
||||
const Axios = require('axios');
|
||||
const Stream = require('stream');
|
||||
const OAuth = require('./OAuth');
|
||||
const Utils = require('./Utils');
|
||||
const Player = require('./Player');
|
||||
const Parser = require('./Parser');
|
||||
const NToken = require('./NToken');
|
||||
const Actions = require('./Actions');
|
||||
const Livechat = require('./Livechat');
|
||||
const Constants = require('./Constants');
|
||||
const SigDecipher = require('./Sig');
|
||||
const EventEmitter = require('events');
|
||||
const Parser = require('./parser');
|
||||
const CancelToken = Axios.CancelToken;
|
||||
const EventEmitter = require('events');
|
||||
|
||||
const OAuth = require('./core/OAuth');
|
||||
const Player = require('./core/Player');
|
||||
const Actions = require('./core/Actions');
|
||||
const Livechat = require('./core/Livechat');
|
||||
|
||||
const Utils = require('./utils/Utils');
|
||||
const Constants = require('./utils/Constants');
|
||||
|
||||
const NToken = require('./deciphers/NToken');
|
||||
const SigDecipher = require('./deciphers/Sig');
|
||||
|
||||
class Innertube {
|
||||
#player;
|
||||
@@ -35,59 +38,60 @@ class Innertube {
|
||||
|
||||
async #init() {
|
||||
const response = await Axios.get(Constants.URLS.YT_BASE, Constants.DEFAULT_HEADERS(this)).catch((error) => error);
|
||||
if (response instanceof Error) throw new Error(`Could not retrieve Innertube session: ${response.message}`);
|
||||
if (response instanceof Error) throw new Utils.InnertubeError('Could not retrieve Innertube session', { status_code: response.status });
|
||||
|
||||
try {
|
||||
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;
|
||||
const data = JSON.parse(`{${Utils.getStringBetweenStrings(response.data, 'ytcfg.set({', '});') || ''}}`);
|
||||
|
||||
this.player_url = data.PLAYER_JS_URL;
|
||||
this.logged_in = data.LOGGED_IN;
|
||||
this.sts = data.STS;
|
||||
if (data.INNERTUBE_CONTEXT) {
|
||||
this.key = data.INNERTUBE_API_KEY;
|
||||
this.version = data.INNERTUBE_API_VERSION;
|
||||
this.context = data.INNERTUBE_CONTEXT;
|
||||
|
||||
this.context.client.hl = 'en';
|
||||
this.context.client.gl = 'US';
|
||||
this.player_url = data.PLAYER_JS_URL;
|
||||
this.logged_in = data.LOGGED_IN;
|
||||
this.sts = data.STS;
|
||||
|
||||
/**
|
||||
* @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.context.client.hl = 'en';
|
||||
this.context.client.gl = 'US';
|
||||
|
||||
this.#player = new Player(this);
|
||||
await this.#player.init();
|
||||
|
||||
if (this.logged_in && this.cookie.length) {
|
||||
this.auth_apisid = Utils.getStringBetweenStrings(this.cookie, 'PAPISID=', ';');
|
||||
this.auth_apisid = Utils.generateSidAuth(this.auth_apisid);
|
||||
}
|
||||
|
||||
// Axios instances
|
||||
this.YTRequester = Axios.create({
|
||||
baseURL: Constants.URLS.YT_BASE_API + this.version,
|
||||
timeout: 15000,
|
||||
headers: Constants.INNERTUBE_HEADERS({ session: this, ytmusic: false }),
|
||||
params: { key: this.key }
|
||||
});
|
||||
|
||||
this.YTMRequester = Axios.create({
|
||||
baseURL: Constants.URLS.YT_MUSIC_BASE_API + this.version,
|
||||
timeout: 15000,
|
||||
headers: Constants.INNERTUBE_HEADERS({ session: this, ytmusic: true }),
|
||||
params: { key: this.key }
|
||||
});
|
||||
|
||||
this.#initMethods();
|
||||
} else {
|
||||
throw new Error('Could not retrieve Innertube session due to unknown reasons');
|
||||
/**
|
||||
* @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.#player = new Player(this);
|
||||
await this.#player.init();
|
||||
|
||||
if (this.logged_in && this.cookie.length) {
|
||||
this.auth_apisid = Utils.getStringBetweenStrings(this.cookie, 'PAPISID=', ';');
|
||||
this.auth_apisid = Utils.generateSidAuth(this.auth_apisid);
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
// Axios instances
|
||||
this.YTRequester = Axios.create({
|
||||
baseURL: Constants.URLS.YT_BASE_API + this.version,
|
||||
timeout: 15000,
|
||||
headers: Constants.INNERTUBE_HEADERS({ session: this, ytmusic: false }),
|
||||
params: { key: this.key }
|
||||
});
|
||||
|
||||
this.YTMRequester = Axios.create({
|
||||
baseURL: Constants.URLS.YT_MUSIC_BASE_API + this.version,
|
||||
timeout: 15000,
|
||||
headers: Constants.INNERTUBE_HEADERS({ session: this, ytmusic: true }),
|
||||
params: { key: this.key }
|
||||
});
|
||||
|
||||
this.#initMethods();
|
||||
} else {
|
||||
this.#retry_count += 1;
|
||||
if (this.#retry_count >= 10) throw new 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;
|
||||
@@ -239,15 +243,15 @@ class Innertube {
|
||||
async #setSetting(setting_id, type, new_value) {
|
||||
const response = await Actions.browse(this, type);
|
||||
|
||||
const contents = response.data.contents.twoColumnBrowseResultsRenderer.tabs[0]
|
||||
.tabRenderer.content.sectionListRenderer.contents[1]
|
||||
.itemSectionRenderer.contents.find((content) => content.settingsOptionsRenderer.options)
|
||||
.settingsOptionsRenderer.options;
|
||||
const contents = ({
|
||||
account_notifications: () => Utils.findNode(response.data, 'contents', 'Your preferences', 13, false).options,
|
||||
account_privacy: () => Utils.findNode(response.data, 'contents', 'settingsSwitchRenderer', 13, false).options
|
||||
})[type.trim()]();
|
||||
|
||||
const option = contents.find((option) => option.settingsSwitchRenderer.enableServiceEndpoint.setSettingEndpoint.settingItemIdForClient == setting_id);
|
||||
|
||||
const setting_item_id = option.settingsSwitchRenderer.enableServiceEndpoint.setSettingEndpoint.settingItemId;
|
||||
const set_setting = await Actions.account(this, 'account/set_setting', { new_value, setting_item_id });
|
||||
const set_setting = await Actions.account(this, 'account/set_setting', { new_value: type == 'account_privacy' ? !new_value : new_value, setting_item_id });
|
||||
|
||||
return {
|
||||
success: set_setting.success,
|
||||
@@ -278,16 +282,16 @@ class Innertube {
|
||||
this.access_token = auth_info.access_token;
|
||||
this.refresh_token = auth_info.refresh_token;
|
||||
this.logged_in = true;
|
||||
|
||||
|
||||
// API key is not needed if logged in via OAuth
|
||||
delete this.YTRequester.defaults.params.key;
|
||||
delete this.YTMRequester.defaults.params.key;
|
||||
|
||||
|
||||
// Update default headers
|
||||
this.YTRequester.defaults.headers = Constants.INNERTUBE_HEADERS({ session: this, ytmusic: false });
|
||||
this.YTMRequester.defaults.headers = Constants.INNERTUBE_HEADERS({ session: this, ytmusic: true });
|
||||
|
||||
resolve();
|
||||
resolve();
|
||||
} else {
|
||||
oauth.on('auth', (data) => {
|
||||
if (data.status === 'SUCCESS') {
|
||||
@@ -295,10 +299,10 @@ class Innertube {
|
||||
this.access_token = data.credentials.access_token;
|
||||
this.refresh_token = data.credentials.refresh_token;
|
||||
this.logged_in = true;
|
||||
|
||||
|
||||
delete this.YTRequester.defaults.params.key;
|
||||
delete this.YTMRequester.defaults.params.key;
|
||||
|
||||
|
||||
this.YTRequester.defaults.headers = Constants.INNERTUBE_HEADERS({ session: this, ytmusic: false });
|
||||
this.YTMRequester.defaults.headers = Constants.INNERTUBE_HEADERS({ session: this, ytmusic: true });
|
||||
|
||||
@@ -317,9 +321,9 @@ class Innertube {
|
||||
*/
|
||||
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,
|
||||
@@ -343,28 +347,29 @@ class Innertube {
|
||||
*/
|
||||
async search(query, options = { client: 'YOUTUBE', period: 'any', order: 'relevance', duration: 'any' }) {
|
||||
const response = await Actions.search(this, options.client, { query, options });
|
||||
if (!response.success) throw new Error(`Could not search on YouTube: ${response.message}`);
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not search on YouTube', response);
|
||||
|
||||
const data = new Parser(this, response.data, {
|
||||
const parsed_data = new Parser(this, response.data, {
|
||||
client: options.client,
|
||||
data_type: 'SEARCH',
|
||||
query
|
||||
}).parse();
|
||||
|
||||
return data;
|
||||
return parsed_data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets 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') {
|
||||
async getSearchSuggestions(input, options = { client: 'YOUTUBE' }) {
|
||||
if (options.client == 'YOUTUBE') {
|
||||
const response = await Actions.getYTSearchSuggestions(this, input);
|
||||
if (!response.success) throw new Error('Could not get search suggestions');
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not get search suggestions', response);
|
||||
|
||||
return response.data[1].map((item) => {
|
||||
return {
|
||||
@@ -372,20 +377,19 @@ class Innertube {
|
||||
bold_text: response.data[0].trim()
|
||||
};
|
||||
});
|
||||
} else if (client == 'YTMUSIC') {
|
||||
} else if (options.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.success) throw new Utils.InnertubeError('Could not get search suggestions', response);
|
||||
if (!response.data.contents) return [];
|
||||
|
||||
const contents = response.data.contents[0].searchSuggestionsSectionRenderer.contents;
|
||||
return contents.map((item) => {
|
||||
let suggestion;
|
||||
|
||||
if (item.historySuggestionRenderer) {
|
||||
suggestion = item.historySuggestionRenderer.suggestion;
|
||||
} else {
|
||||
suggestion = item.searchSuggestionRenderer.suggestion;
|
||||
}
|
||||
item.historySuggestionRenderer &&
|
||||
(suggestion = item.historySuggestionRenderer.suggestion) ||
|
||||
(suggestion = item.searchSuggestionRenderer.suggestion);
|
||||
|
||||
return {
|
||||
text: suggestion.runs.map((run) => run.text).join('').trim(),
|
||||
@@ -396,31 +400,20 @@ class Innertube {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets details for a video.
|
||||
*
|
||||
* @param {string} video_id - The id of the video.
|
||||
* @return {Promise.<{ title: string; description: string; thumbnail: []; metadata: {} }>}
|
||||
*/
|
||||
* Gets details for a video.
|
||||
*
|
||||
* @param {string} video_id - The id of the video.
|
||||
* @return {Promise.<{ title: string; description: string; thumbnail: []; metadata: {} }>}
|
||||
*/
|
||||
async getDetails(video_id) {
|
||||
if (!video_id) throw new 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 });
|
||||
const continuation = await Actions.getContinuation(this, { video_id });
|
||||
const continuation = await Actions.next(this, { video_id });
|
||||
data.continuation = continuation.data;
|
||||
|
||||
|
||||
const details = new Parser(this, data, { client: 'YOUTUBE', data_type: 'VIDEO_INFO' }).parse();
|
||||
|
||||
if (details.metadata.is_live_content) {
|
||||
const data_continuation = await Actions.getContinuation(this, { video_id });
|
||||
if (data_continuation.data.contents.twoColumnWatchNextResults.conversationBar) {
|
||||
details.getLivechat = () => new Livechat(this, data_continuation.data.contents.twoColumnWatchNextResults.conversationBar.liveChatRenderer.continuations[0].reloadContinuationData.continuation, details.metadata.channel_id, video_id);
|
||||
} else {
|
||||
details.getLivechat = () => { };
|
||||
}
|
||||
} else {
|
||||
details.getLivechat = () => { };
|
||||
}
|
||||
|
||||
// Functions
|
||||
details.like = () => Actions.engage(this, 'like/like', { video_id });
|
||||
details.dislike = () => Actions.engage(this, 'like/dislike', { video_id });
|
||||
@@ -428,26 +421,116 @@ class Innertube {
|
||||
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 = () => this.getComments(video_id);
|
||||
details.getComments = () => this.getComments(video_id, { channel_id: details.metadata.channel_id });
|
||||
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.changeNotificationPreferences = (type) => Actions.notifications(this, 'modify_channel_preference', { channel_id: details.metadata.channel_id, pref: type || 'NONE' });
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the 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 });
|
||||
* Gets info about 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 lyrics_tab = data_continuation.data.contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer
|
||||
const tabs = response.data.contents.twoColumnBrowseResultsRenderer.tabs;
|
||||
const metadata = response.data.metadata;
|
||||
|
||||
const home_tab = tabs.find((tab) => tab.tabRenderer.title == 'Home');
|
||||
const home_contents = home_tab.tabRenderer.content.sectionListRenderer.contents;
|
||||
const home_shelves = [];
|
||||
|
||||
home_contents.forEach((content) => {
|
||||
if (!content.itemSectionRenderer) return;
|
||||
|
||||
const contents = content.itemSectionRenderer.contents[0];
|
||||
|
||||
const list = contents?.shelfRenderer?.content.horizontalListRenderer;
|
||||
if (!list) return; // For now we'll support only videos & playlists; TODO: Handle featured channels
|
||||
|
||||
const shelf = {
|
||||
title: contents.shelfRenderer.title.runs[0].text,
|
||||
content: []
|
||||
};
|
||||
|
||||
shelf.content = list.items.map((item) => {
|
||||
const renderer = item.gridVideoRenderer || item.gridPlaylistRenderer;
|
||||
if (renderer.videoId) {
|
||||
return {
|
||||
id: renderer?.videoId,
|
||||
title: renderer?.title?.simpleText,
|
||||
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',
|
||||
},
|
||||
thumbnail: renderer?.thumbnail?.thumbnails?.slice(-1)[0] || {},
|
||||
moving_thumbnail: renderer?.richThumbnail?.movingThumbnailRenderer?.movingThumbnailDetails?.thumbnails[0] || {},
|
||||
published: renderer?.publishedTimeText?.simpleText || 'N/A',
|
||||
badges: renderer?.badges?.map((badge) => badge.metadataBadgeRenderer.label) || [],
|
||||
owner_badges: renderer?.ownerBadges?.map((badge) => badge.metadataBadgeRenderer.tooltip) || []
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
id: renderer?.playlistId,
|
||||
title: renderer?.title?.runs?.map((run) => run.text).join(''),
|
||||
metadata: {
|
||||
thumbnail: renderer?.thumbnail?.thumbnails?.slice(-1)[0] || {},
|
||||
video_count: renderer?.videoCountShortText?.simpleText || 'N/A',
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
home_shelves.push(shelf);
|
||||
});
|
||||
|
||||
return {
|
||||
title: metadata.channelMetadataRenderer.title,
|
||||
description: metadata.channelMetadataRenderer.description,
|
||||
metadata: {
|
||||
url: metadata.channelMetadataRenderer?.channelUrl,
|
||||
rss_urls: metadata.channelMetadataRenderer?.rssUrl,
|
||||
vanity_channel_url: metadata.channelMetadataRenderer?.vanityChannelUrl,
|
||||
external_id: metadata.channelMetadataRenderer?.externalId,
|
||||
is_family_safe: metadata.channelMetadataRenderer?.isFamilySafe,
|
||||
keywords: metadata.channelMetadataRenderer?.keywords
|
||||
},
|
||||
content: {
|
||||
// Home page of the channel, always available in the first request.
|
||||
home_page: home_shelves,
|
||||
|
||||
// Functions— these will need additional requests and will possibly use the parser.
|
||||
getVideos: () => {},
|
||||
getPlaylists: () => {},
|
||||
getCommunity: () => {},
|
||||
getChannels: () => {},
|
||||
getAbout: () => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the lyrics for a given song if available.
|
||||
*
|
||||
* @param {string} video_id
|
||||
* @returns {Promise.<string>} Song lyrics
|
||||
*/
|
||||
async getLyrics(video_id) {
|
||||
const continuation = await Actions.next(this, { video_id: video_id, ytmusic: true });
|
||||
if (!continuation.success) throw new Utils.InnertubeError('Could not retrieve lyrics', continuation);
|
||||
|
||||
const lyrics_tab = continuation.data.contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer
|
||||
.watchNextTabbedResultsRenderer.tabs.find((obj) => obj.tabRenderer.title == 'Lyrics');
|
||||
|
||||
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);
|
||||
if (!response.success || !response.data?.contents?.sectionListRenderer) throw new Utils.UnavailableContentError('Lyrics not available', { video_id });
|
||||
|
||||
const lyrics = response.data.contents.sectionListRenderer.contents[0].musicDescriptionShelfRenderer.description.runs[0].text;
|
||||
return lyrics;
|
||||
@@ -470,55 +553,84 @@ class Innertube {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
* Gets the comments section of a video.
|
||||
*
|
||||
* @param {string} video_id - The id of the video.
|
||||
* @param {string} [data] - Video data and continuation token (optional).
|
||||
* @return {Promise.<[{ comments: []; comment_count?: string }]>
|
||||
*/
|
||||
async getComments(video_id, data = {}) {
|
||||
let comment_section_token;
|
||||
|
||||
if (!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;
|
||||
if (!data.token) {
|
||||
const continuation = await Actions.next(this, { video_id });
|
||||
if (!continuation.success) throw new Utils.InnertubeError('Could not fetch comments section', continuation);
|
||||
|
||||
const contents = Utils.findNode(continuation.data, 'contents', 'comments-section', 5);
|
||||
const item_section_renderer = contents.find((item) => item.itemSectionRenderer).itemSectionRenderer;
|
||||
comment_section_token = item_section_renderer?.contents[0]?.continuationItemRenderer?.continuationEndpoint.continuationCommand.token;
|
||||
|
||||
const secondary_info_renderer = contents.find((item) => item.videoSecondaryInfoRenderer).videoSecondaryInfoRenderer;
|
||||
data.channel_id = secondary_info_renderer.owner.videoOwnerRenderer.navigationEndpoint.browseEndpoint.browseId;
|
||||
}
|
||||
|
||||
const response = await Actions.getContinuation(this, { continuation_token: comment_section_token || token });
|
||||
if (!response.success) throw new Error('Could not fetch comments section');
|
||||
const response = await Actions.next(this, { continuation_token: comment_section_token || data.token });
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not fetch comments section', response);
|
||||
|
||||
const comments_section = { comments: [] };
|
||||
!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');
|
||||
!data.token && (comments_section.comment_count = 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);
|
||||
!data.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) ||
|
||||
(continuation_token = response.data?.onResponseReceivedEndpoints[0]?.appendContinuationItemsAction?.continuationItems
|
||||
?.find((item) => item.continuationItemRenderer)?.continuationItemRenderer?.button.buttonRenderer.command.continuationCommand.token));
|
||||
|
||||
comments_section.getContinuation = () => this.getComments(video_id, continuation_token);
|
||||
continuation_token && (comments_section.getContinuation = () => this.getComments(video_id, { token: continuation_token, channel_id: data.channel_id }));
|
||||
|
||||
let contents;
|
||||
!token && (contents = response.data.onResponseReceivedEndpoints[1].reloadContinuationItemsCommand.continuationItems) ||
|
||||
!data.token && (contents = response.data.onResponseReceivedEndpoints[1].reloadContinuationItemsCommand.continuationItems) ||
|
||||
(contents = response.data.onResponseReceivedEndpoints[0].appendContinuationItemsAction.continuationItems);
|
||||
|
||||
contents.forEach((thread) => {
|
||||
if (!thread.commentThreadRenderer) return;
|
||||
contents.forEach((content) => {
|
||||
const thread = content?.commentThreadRenderer?.comment.commentRenderer || content?.commentRenderer;
|
||||
if (!thread) return;
|
||||
|
||||
const replies_token = content?.commentThreadRenderer?.replies?.commentRepliesRenderer.contents
|
||||
.find((content) => content.continuationItemRenderer.continuationEndpoint)
|
||||
.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
|
||||
|
||||
const like_btn = thread?.actionButtons?.commentActionButtonsRenderer.likeButton;
|
||||
const dislike_btn = thread?.actionButtons?.commentActionButtonsRenderer.dislikeButton;
|
||||
|
||||
const comment = {
|
||||
text: thread.commentThreadRenderer.comment.commentRenderer.contentText.runs.map((t) => t.text).join(' '),
|
||||
text: thread.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
|
||||
name: thread.authorText.simpleText,
|
||||
thumbnail: thread.authorThumbnail.thumbnails,
|
||||
channel_id: thread.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,
|
||||
}
|
||||
published: thread.publishedTimeText.runs[0].text,
|
||||
is_liked: like_btn?.toggleButtonRenderer.isToggled,
|
||||
is_disliked: dislike_btn?.toggleButtonRenderer.isToggled,
|
||||
is_pinned: thread.pinnedCommentBadge && true || false,
|
||||
is_channel_owner: thread.authorIsChannelOwner,
|
||||
like_count: thread?.voteCount?.simpleText || '0',
|
||||
reply_count: thread.replyCount || 0,
|
||||
id: thread.commentId,
|
||||
},
|
||||
like: () => Actions.engage(this, 'comment/perform_comment_action', { comment_action: 'like', comment_id: thread.commentId, video_id, channel_id: data.channel_id }),
|
||||
dislike: () => Actions.engage(this, 'comment/perform_comment_action', { comment_action: 'dislike', comment_id: thread.commentId, video_id, channel_id: data.channel_id }),
|
||||
reply: (text) => Actions.engage(this, 'comment/create_comment_reply', { text, comment_id: thread.commentId, video_id }),
|
||||
getReplies: () => this.getComments(video_id, { token: replies_token, channel_id: data.channel_id })
|
||||
};
|
||||
|
||||
!replies_token && (delete comment.getReplies);
|
||||
|
||||
comments_section.comments.push(comment);
|
||||
});
|
||||
|
||||
@@ -527,168 +639,247 @@ class Innertube {
|
||||
|
||||
/**
|
||||
* Returns your watch history.
|
||||
* @returns {Promise.<[{ id: string; title: string; channel: string; metadata: {} }]>}
|
||||
* @returns {Promise.<{ items: [{ date: string; videos: [] }] }>}
|
||||
*/
|
||||
async getHistory() {
|
||||
const response = await Actions.browse(this, 'history');
|
||||
const contents = response.data.contents.twoColumnBrowseResultsRenderer.tabs[0]
|
||||
.tabRenderer.content.sectionListRenderer.contents;
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve watch history', response);
|
||||
|
||||
const history = [];
|
||||
const contents = Utils.findNode(response, 'contents', 'videoRenderer', 9, false)
|
||||
|
||||
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(' '),
|
||||
description: item.videoRenderer.descriptionSnippet && item.videoRenderer.descriptionSnippet.runs[0].text || 'N/A',
|
||||
channel: {
|
||||
name: item.videoRenderer.shortBylineText && item.videoRenderer.shortBylineText.runs[0].text || 'N/A',
|
||||
url: item.videoRenderer.shortBylineText && `${Constants.URLS.YT_BASE}${item.videoRenderer.shortBylineText.runs[0].navigationEndpoint.commandMetadata.webCommandMetadata.url}` || 'N/A',
|
||||
},
|
||||
metadata: {
|
||||
view_count: item.videoRenderer.viewCountText && item.videoRenderer.viewCountText.simpleText || 'N/A',
|
||||
short_view_count_text: {
|
||||
simple_text: item.videoRenderer.shortViewCountText && item.videoRenderer.shortViewCountText.simpleText || 'N/A',
|
||||
accessibility_label: item.videoRenderer.shortViewCountText && (item.videoRenderer.shortViewCountText.accessibility && item.videoRenderer.shortViewCountText.accessibility.accessibilityData.label || 'N/A') || 'N/A',
|
||||
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 = contents.map((item) => {
|
||||
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}`
|
||||
},
|
||||
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: {
|
||||
seconds: Utils.timeToSeconds(item.videoRenderer.lengthText && item.videoRenderer.lengthText.simpleText || '0'),
|
||||
simple_text: item.videoRenderer.lengthText && item.videoRenderer.lengthText.simpleText || 'N/A',
|
||||
accessibility_label: item.videoRenderer.lengthText && item.videoRenderer.lengthText.accessibility.accessibilityData.label || 'N/A'
|
||||
},
|
||||
badges: item.videoRenderer.badges && item.videoRenderer.badges.map((badge) => badge.metadataBadgeRenderer.label) || [],
|
||||
owner_badges: item.videoRenderer.ownerBadges && item.videoRenderer.ownerBadges.map((badge) => badge.metadataBadgeRenderer.tooltip) || []
|
||||
}
|
||||
};
|
||||
history.push(content);
|
||||
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,
|
||||
},
|
||||
thumbnail: item?.videoRenderer?.thumbnail?.thumbnails?.slice(-1)[0] || [],
|
||||
moving_thumbnail: item?.videoRenderer?.richThumbnail?.movingThumbnailRenderer?.movingThumbnailDetails?.thumbnails[0] || [],
|
||||
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) || []
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
history.items.push({
|
||||
date: section_title,
|
||||
videos: section_items
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return history;
|
||||
history.getContinuation = async () => {
|
||||
const citem = contents.find((item) => item.continuationItemRenderer);
|
||||
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
|
||||
|
||||
const response = await Actions.browse(this, 'continuation', { ctoken });
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response);
|
||||
|
||||
history.items = [];
|
||||
|
||||
return parseItems(response.data.onResponseReceivedActions[0].appendContinuationItemsAction.continuationItems);
|
||||
}
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
return parseItems(contents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns YouTube's home feed (aka recommendations).
|
||||
* @returns {Promise.<[{ id: string; title: string; channel: string; metadata: {} }]>}
|
||||
*/
|
||||
* Returns YouTube's home feed (aka recommendations).
|
||||
* @returns {Promise.<{ videos: [{ id: string; title: string; description: string; channel: string; metadata: object }] }>}
|
||||
*/
|
||||
async getHomeFeed() {
|
||||
const response = await Actions.browse(this, 'home_feed');
|
||||
if (!response.success) throw new Error('Could not get home feed');
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve home feed', response);
|
||||
|
||||
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;
|
||||
const contents = Utils.findNode(response, 'contents', 'videoRenderer', 9, false)
|
||||
|
||||
if (content) return {
|
||||
id: content.videoRenderer.videoId,
|
||||
title: content.videoRenderer.title.runs.map((run) => run.text).join(' '),
|
||||
description: content.videoRenderer.descriptionSnippet && content.videoRenderer.descriptionSnippet.runs[0].text || 'N/A',
|
||||
channel: {
|
||||
name: content.videoRenderer.shortBylineText && content.videoRenderer.shortBylineText.runs[0].text || 'N/A',
|
||||
url: content.videoRenderer.shortBylineText && `${Constants.URLS.YT_BASE}${content.videoRenderer.shortBylineText.runs[0].navigationEndpoint.commandMetadata.webCommandMetadata.url}` || 'N/A',
|
||||
},
|
||||
metadata: {
|
||||
view_count: content.videoRenderer.viewCountText && content.videoRenderer.viewCountText.simpleText || 'N/A',
|
||||
short_view_count_text: {
|
||||
simple_text: content.videoRenderer.shortViewCountText && content.videoRenderer.shortViewCountText.simpleText || 'N/A',
|
||||
accessibility_label: content.videoRenderer.shortViewCountText && (content.videoRenderer.shortViewCountText.accessibility && content.videoRenderer.shortViewCountText.accessibility.accessibilityData.label || 'N/A') || 'N/A',
|
||||
const parseItems = (contents) => {
|
||||
const videos = contents.map((item) => {
|
||||
const content = item.richItemRenderer && item.richItemRenderer.content.videoRenderer &&
|
||||
item.richItemRenderer.content;
|
||||
|
||||
if (content) return {
|
||||
id: content.videoRenderer.videoId,
|
||||
title: content.videoRenderer.title.runs.map((run) => run.text).join(' '),
|
||||
description: content?.videoRenderer?.descriptionSnippet?.runs[0]?.text || 'N/A',
|
||||
channel: {
|
||||
id: content?.videoRenderer?.shortBylineText?.runs[0]?.navigationEndpoint?.browseEndpoint?.browseId,
|
||||
name: content?.videoRenderer?.shortBylineText?.runs[0]?.text || 'N/A',
|
||||
url: `${Constants.URLS.YT_BASE}${content?.videoRenderer?.shortBylineText?.runs[0]?.navigationEndpoint?.browseEndpoint?.canonicalBaseUrl}`
|
||||
},
|
||||
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',
|
||||
duration: {
|
||||
seconds: Utils.timeToSeconds(content.videoRenderer.lengthText && content.videoRenderer.lengthText.simpleText || '0'),
|
||||
simple_text: content.videoRenderer.lengthText && content.videoRenderer.lengthText.simpleText || 'N/A',
|
||||
accessibility_label: content.videoRenderer.lengthText && content.videoRenderer.lengthText.accessibility.accessibilityData.label || '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) || []
|
||||
metadata: {
|
||||
view_count: content?.videoRenderer?.viewCountText?.simpleText || 'N/A',
|
||||
short_view_count_text: {
|
||||
simple_text: content?.videoRenderer?.shortViewCountText?.simpleText || 'N/A',
|
||||
accessibility_label: content?.videoRenderer?.shortViewCountText?.accessibility?.accessibilityData?.label || 'N/A',
|
||||
},
|
||||
thumbnail: content?.videoRenderer?.thumbnail?.thumbnails.slice(-1)[0] || {},
|
||||
moving_thumbnail: content?.videoRenderer?.richThumbnail?.movingThumbnailRenderer?.movingThumbnailDetails?.thumbnails[0] || {},
|
||||
published: content?.videoRenderer?.publishedTimeText?.simpleText || 'N/A',
|
||||
duration: {
|
||||
seconds: Utils.timeToSeconds(content?.videoRenderer?.lengthText?.simpleText || '0'),
|
||||
simple_text: content?.videoRenderer?.lengthText?.simpleText || 'N/A',
|
||||
accessibility_label: content?.videoRenderer?.lengthText?.accessibility?.accessibilityData?.label || 'N/A'
|
||||
},
|
||||
badges: content?.videoRenderer?.badges?.map((badge) => badge.metadataBadgeRenderer.label) || [],
|
||||
owner_badges: content?.videoRenderer?.ownerBadges?.map((badge) => badge.metadataBadgeRenderer.tooltip) || []
|
||||
}
|
||||
}
|
||||
}).filter((item) => item);
|
||||
|
||||
const getContinuation = async () => {
|
||||
const citem = contents.find((item) => item.continuationItemRenderer);
|
||||
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
|
||||
|
||||
const response = await Actions.browse(this, 'continuation', { ctoken });
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response);
|
||||
|
||||
return parseItems(response.data.onResponseReceivedActions[0].appendContinuationItemsAction.continuationItems);
|
||||
}
|
||||
}).filter((video) => video);
|
||||
|
||||
return { videos, getContinuation };
|
||||
}
|
||||
|
||||
return parseItems(contents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns your subscription feed.
|
||||
* @returns {Promise.<{ today: []; yesterday: []; this_week: [] }>}
|
||||
*/
|
||||
* Returns your subscription feed.
|
||||
* @returns {Promise.<{ items: [{ date: string; videos: [] }] }>}
|
||||
*/
|
||||
async getSubscriptionsFeed() {
|
||||
const response = await Actions.browse(this, 'subscriptions_feed');
|
||||
if (!response.success) throw new Error('Could not get subscriptions feed');
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve subscriptions feed', response);
|
||||
|
||||
const contents = response.data.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents;
|
||||
const subscriptions_feed = {};
|
||||
const contents = Utils.findNode(response, 'contents', 'contents', 9, false);
|
||||
|
||||
contents.forEach((section) => {
|
||||
if (!section.itemSectionRenderer) return;
|
||||
const subsfeed = { items: [] };
|
||||
|
||||
const section_contents = section.itemSectionRenderer.contents[0];
|
||||
const section_items = section_contents.shelfRenderer.content.gridRenderer.items;
|
||||
const parseItems = (contents) => {
|
||||
contents.forEach((section) => {
|
||||
if (!section.itemSectionRenderer) return;
|
||||
|
||||
const key = section_contents.shelfRenderer.title.runs[0].text;
|
||||
subscriptions_feed[key.toLowerCase().replace(/ +/g, '_')] = [];
|
||||
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;
|
||||
|
||||
section_items.forEach((item) => {
|
||||
const content = {
|
||||
id: item.gridVideoRenderer.videoId,
|
||||
title: item.gridVideoRenderer.title.runs.map((run) => run.text).join(' '),
|
||||
channel: {
|
||||
name: item.gridVideoRenderer.shortBylineText && item.gridVideoRenderer.shortBylineText.runs[0].text || 'N/A',
|
||||
url: item.gridVideoRenderer.shortBylineText && `${Constants.URLS.YT_BASE}${item.gridVideoRenderer.shortBylineText.runs[0].navigationEndpoint.commandMetadata.webCommandMetadata.url}` || 'N/A',
|
||||
},
|
||||
metadata: {
|
||||
view_count: item.gridVideoRenderer.viewCountText && item.gridVideoRenderer.viewCountText.simpleText || 'N/A',
|
||||
short_view_count_text: {
|
||||
simple_text: item.gridVideoRenderer.shortViewCountText && item.gridVideoRenderer.shortViewCountText.simpleText || 'N/A',
|
||||
accessibility_label: item.gridVideoRenderer.shortViewCountText && (item.gridVideoRenderer.shortViewCountText.accessibility && item.gridVideoRenderer.shortViewCountText.accessibility.accessibilityData.label || 'N/A') || 'N/A',
|
||||
const items = section_items.map((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}`
|
||||
},
|
||||
thumbnail: item.gridVideoRenderer.thumbnail && item.gridVideoRenderer.thumbnail.thumbnails.slice(-1)[0] || [],
|
||||
moving_thumbnail: item.gridVideoRenderer.richThumbnail && item.gridVideoRenderer.richThumbnail.movingThumbnailRenderer.movingThumbnailDetails.thumbnails[0] || {},
|
||||
published: item.gridVideoRenderer.publishedTimeText && item.gridVideoRenderer.publishedTimeText.simpleText || 'N/A',
|
||||
badges: item.gridVideoRenderer.badges && item.gridVideoRenderer.badges.map((badge) => badge.metadataBadgeRenderer.label) || [],
|
||||
owner_badges: item.gridVideoRenderer.ownerBadges && item.gridVideoRenderer.ownerBadges.map((badge) => badge.metadataBadgeRenderer.tooltip) || []
|
||||
}
|
||||
};
|
||||
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) || []
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
subscriptions_feed[key.toLowerCase().replace(/ +/g, '_')].push(content);
|
||||
subsfeed.items.push({
|
||||
date: section_title,
|
||||
videos: items
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return subscriptions_feed;
|
||||
subsfeed.getContinuation = async () => {
|
||||
const citem = contents.find((item) => item.continuationItemRenderer);
|
||||
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
|
||||
|
||||
const response = await Actions.browse(this, 'continuation', { ctoken });
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response);
|
||||
|
||||
const ccontents = Utils.findNode(response.data, 'onResponseReceivedActions', 'itemSectionRenderer', 4, false);
|
||||
subsfeed.items = [];
|
||||
|
||||
return parseItems(ccontents);
|
||||
}
|
||||
|
||||
return subsfeed;
|
||||
};
|
||||
|
||||
return parseItems(contents);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }]>}
|
||||
* @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 Error('Could not fetch notifications');
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not fetch notifications', response);
|
||||
|
||||
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);
|
||||
if (!contents.multiPageMenuNotificationSectionRenderer) throw new Utils.InnertubeError('No notifications', response);
|
||||
|
||||
const parseItems = (items) => {
|
||||
const parsed_items = 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 || '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,
|
||||
};
|
||||
}).filter((notification) => notification);
|
||||
|
||||
const getContinuation = async () => {
|
||||
const citem = items.find((item) => item.continuationItemRenderer);
|
||||
const ctoken = citem?.continuationItemRenderer?.continuationEndpoint?.getNotificationMenuEndpoint?.ctoken;
|
||||
|
||||
const response = await Actions.notifications(this, 'get_notification_menu', { ctoken });
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response);
|
||||
|
||||
return parseItems(response.data.actions[0].appendContinuationItemsAction.continuationItems);
|
||||
}
|
||||
|
||||
return { items: parsed_items, getContinuation };
|
||||
}
|
||||
|
||||
return parseItems(contents.multiPageMenuNotificationSectionRenderer.items);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -697,7 +888,7 @@ class Innertube {
|
||||
*/
|
||||
async getUnseenNotificationsCount() {
|
||||
const response = await Actions.notifications(this, 'get_unseen_count');
|
||||
if (!response.success) throw new Error('Could not get unseen notifications count');
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not get unseen notifications count', response);
|
||||
return response.data.unseenCount;
|
||||
}
|
||||
|
||||
@@ -725,9 +916,9 @@ class Innertube {
|
||||
const url_components = new URL(format.url);
|
||||
url_components.searchParams.set('cver', this.context.client.clientVersion);
|
||||
url_components.searchParams.set('ratebypass', 'yes');
|
||||
|
||||
|
||||
if (url_components.searchParams.get('n')) {
|
||||
url_components.searchParams.set('n', new NToken(this.#player.ntoken_sc).transform(url_components.searchParams.get('n')));
|
||||
url_components.searchParams.set('n', new NToken(this.#player.ntoken_sc, url_components.searchParams.get('n')).transform());
|
||||
}
|
||||
|
||||
format.url = url_components.toString();
|
||||
@@ -788,7 +979,7 @@ class Innertube {
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -804,7 +995,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';
|
||||
@@ -825,7 +1016,7 @@ class Innertube {
|
||||
if (!format)
|
||||
return stream.emit('error', { message: 'Could not find any suitable format.', type: 'FORMAT_UNAVAILABLE' });
|
||||
|
||||
const video_details = new Parser(this, video_data, { client: 'YOUTUBE', data_type: 'VIDEO_INFO', desktop_v: true }).parse();
|
||||
const video_details = new Parser(this, video_data, { client: 'YOUTUBE', data_type: 'VIDEO_INFO' }).parse();
|
||||
stream.emit('info', { video_details, selected_format: format, formats });
|
||||
|
||||
if (options.type == 'videoandaudio' && !options.range) {
|
||||
@@ -943,4 +1134,4 @@ class Innertube {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Innertube;
|
||||
module.exports = Innertube;
|
||||
312
lib/Parser.js
312
lib/Parser.js
@@ -1,312 +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 {
|
||||
id: video.videoId,
|
||||
title: video.title.runs[0].text,
|
||||
description: video.detailedMetadataSnippets && video.detailedMetadataSnippets[0].snippetText.runs.map((item) => item.text).join('') || 'N/A',
|
||||
channel_url: `${Constants.URLS.YT_BASE}${video.ownerText.runs[0].navigationEndpoint.commandMetadata.webCommandMetadata.url}`,
|
||||
author: video.ownerText.runs[0].text,
|
||||
url: `https://youtu.be/${video.videoId}`,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Video data is parsed dynamically, so if youtube decides to add something new we won't have to change anything here.
|
||||
*/
|
||||
#parseVideoInfo() {
|
||||
const playability_status = this.data.playabilityStatus;
|
||||
|
||||
if (playability_status.status == 'ERROR')
|
||||
throw new Error(`Could not retrieve video details: ${playability_status.status} - ${playability_status.reason}`);
|
||||
|
||||
const details = this.data.videoDetails;
|
||||
const microformat = this.data.microformat.playerMicroformatRenderer;
|
||||
const streaming_data = this.data.streamingData;
|
||||
|
||||
const response = {
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
thumbnail: [],
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
const mf_raw_data = Object.entries(microformat);
|
||||
const dt_raw_data = Object.entries(details);
|
||||
|
||||
// Extracts most of the metadata
|
||||
mf_raw_data.forEach((entry) => {
|
||||
const key = Utils.camelToSnake(entry[0]);
|
||||
if (Constants.METADATA_KEYS.includes(key)) {
|
||||
key == 'view_count' && (response.metadata[key] = parseInt(entry[1])) ||
|
||||
key == 'owner_profile_url' && (response.metadata.channel_url = entry[1]) ||
|
||||
key == 'owner_channel_name' && (response.metadata.channel_name = entry[1]) ||
|
||||
(response.metadata[key] = entry[1]);
|
||||
} else {
|
||||
response[key] = entry[1];
|
||||
}
|
||||
});
|
||||
|
||||
// Extracts extra details
|
||||
dt_raw_data.forEach((entry) => {
|
||||
const key = Utils.camelToSnake(entry[0]);
|
||||
if (Constants.BLACKLISTED_KEYS.includes(key)) return;
|
||||
if (Constants.METADATA_KEYS.includes(key)) {
|
||||
key == 'view_count' && (response.metadata[key] = parseInt(entry[1])) ||
|
||||
(response.metadata[key] = entry[1]);
|
||||
} else {
|
||||
key == 'short_description' && (response.description = entry[1]) ||
|
||||
key == 'thumbnail' && (response.thumbnail = entry[1].thumbnails.slice(-1)[0]) ||
|
||||
key == 'video_id' && (response.id = entry[1]) ||
|
||||
(response[key] = entry[1]);
|
||||
}
|
||||
});
|
||||
|
||||
// Data continuation is only required in getDetails()
|
||||
if (this.data.continuation) {
|
||||
const primary_info_renderer = this.data.continuation.contents.twoColumnWatchNextResults
|
||||
.results.results.contents.find((item) => item.videoPrimaryInfoRenderer).videoPrimaryInfoRenderer;
|
||||
|
||||
const secondary_info_renderer = this.data.continuation.contents.twoColumnWatchNextResults
|
||||
.results.results.contents.find((item) => item.videoSecondaryInfoRenderer).videoSecondaryInfoRenderer;
|
||||
|
||||
const like_btn = primary_info_renderer.videoActions.menuRenderer
|
||||
.topLevelButtons.find((item) => item.toggleButtonRenderer.defaultIcon.iconType == 'LIKE');
|
||||
|
||||
const dislike_btn = primary_info_renderer.videoActions.menuRenderer
|
||||
.topLevelButtons.find((item) => item.toggleButtonRenderer.defaultIcon.iconType == 'DISLIKE');
|
||||
|
||||
const notification_toggle_btn = secondary_info_renderer.subscribeButton.subscribeButtonRenderer
|
||||
?.notificationPreferenceButton?.subscriptionNotificationToggleButtonRenderer;
|
||||
|
||||
// These will always be false if logged out.
|
||||
response.metadata.is_liked = like_btn.toggleButtonRenderer.isToggled;
|
||||
response.metadata.is_disliked = dislike_btn.toggleButtonRenderer.isToggled;
|
||||
response.metadata.is_subscribed = secondary_info_renderer.subscribeButton.subscribeButtonRenderer?.subscribed || false;
|
||||
|
||||
response.metadata.subscriber_count = secondary_info_renderer.owner.videoOwnerRenderer?.subscriberCountText?.simpleText || 'N/A';
|
||||
response.metadata.current_notification_preference = notification_toggle_btn?.states.find((state) => state.stateId == notification_toggle_btn.currentStateId)
|
||||
.state.buttonRenderer.icon.iconType || 'N/A';
|
||||
|
||||
// Simpler version of publish_date
|
||||
response.metadata.publish_date_text = primary_info_renderer.dateText.simpleText;
|
||||
|
||||
// Only parse like count if it's enabled
|
||||
if (response.metadata.allow_ratings) {
|
||||
response.metadata.likes = {
|
||||
count: parseInt(like_btn.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, '')),
|
||||
short_count_text: like_btn.toggleButtonRenderer.defaultText.simpleText
|
||||
};
|
||||
}
|
||||
|
||||
response.metadata.owner_badges = secondary_info_renderer.owner.videoOwnerRenderer?.badges?.map((badge) => badge.metadataBadgeRenderer.tooltip) || [];
|
||||
}
|
||||
|
||||
streaming_data && streaming_data.adaptiveFormats &&
|
||||
(response.metadata.available_qualities = [...new Set(streaming_data.adaptiveFormats.filter(v => v.qualityLabel)
|
||||
.map(v => v.qualityLabel).sort((a, b) => +a.replace(/\D/gi, '') - +b.replace(/\D/gi, '')))]) ||
|
||||
(response.metadata.available_qualities = []);
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Parser;
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
const Uuid = require('uuid');
|
||||
const Axios = require('axios');
|
||||
const Utils = require('./Utils');
|
||||
const Constants = require('./Constants');
|
||||
const Proto = require('../proto');
|
||||
const Constants = require('../utils/Constants');
|
||||
|
||||
/**
|
||||
* Performs direct interactions on YouTube.
|
||||
@@ -14,7 +14,7 @@ const Constants = require('./Constants');
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
|
||||
*/
|
||||
async function engage(session, engagement_type, args = {}) {
|
||||
if (!session.logged_in) throw new Error('You are not signed-in');
|
||||
if (!session.logged_in) throw new Error('You are not signed in');
|
||||
|
||||
const data = { context: session.context };
|
||||
switch (engagement_type) {
|
||||
@@ -32,13 +32,25 @@ async function engage(session, engagement_type, args = {}) {
|
||||
break;
|
||||
case 'comment/create_comment':
|
||||
data.commentText = args.text;
|
||||
data.createCommentParams = Utils.encodeCommentParams(args.video_id);
|
||||
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, args.channel_id),
|
||||
dislike: () => Proto.encodeCommentActionParams(4, args.comment_id, args.video_id, args.channel_id),
|
||||
})[args.comment_action]();
|
||||
data.actions = [action];
|
||||
break;
|
||||
default:
|
||||
throw new Utils.InnertubeError('Invalid action', action);
|
||||
}
|
||||
|
||||
const response = await session.YTRequester.post(`/${engagement_type}`, JSON.stringify(data));
|
||||
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
|
||||
|
||||
const response = await session.YTRequester.post(`/${engagement_type}`, JSON.stringify(data)).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -50,16 +62,18 @@ async function engage(session, engagement_type, args = {}) {
|
||||
* Accesses YouTube's various sections.
|
||||
*
|
||||
* @param {Innertube} session - A valid Innertube session.
|
||||
* @param {string} action_type - Type of action.
|
||||
* @param {string} action - Type of action.
|
||||
* @param {object} args - Action argumenets.
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; 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');
|
||||
async function browse(session, action, args = {}) {
|
||||
if (!session.logged_in && action != 'home_feed' &&
|
||||
action !== 'lyrics' && action !== 'music_playlist' &&
|
||||
action !== 'playlist')
|
||||
throw new Error('You are not signed in');
|
||||
|
||||
const data = { context: session.context };
|
||||
switch (action_type) {
|
||||
switch (action) {
|
||||
case 'account_notifications':
|
||||
data.browseId = 'SPaccount_notifications';
|
||||
break;
|
||||
@@ -82,19 +96,24 @@ async function browse(session, action_type, args = {}) {
|
||||
context.client.originalUrl = Constants.URLS.YT_MUSIC;
|
||||
context.client.clientVersion = Constants.YTMUSIC_VERSION;
|
||||
context.client.clientName = 'WEB_REMIX';
|
||||
|
||||
|
||||
data.context = context;
|
||||
data.browseId = args.browse_id;
|
||||
break;
|
||||
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 requester = args.ytmusic && session.YTMRequester || session.YTRequester;
|
||||
const response = await requester.post('/browse', JSON.stringify(data));
|
||||
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
|
||||
const response = await requester.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,
|
||||
@@ -108,29 +127,29 @@ async function browse(session, action_type, args = {}) {
|
||||
* Account settings endpoints.
|
||||
*
|
||||
* @param {Innertube} session - A valid Innertube session.
|
||||
* @param {string} action_type - Type of action.
|
||||
* @param {string} action - Type of action.
|
||||
* @param {object} args - Action argumenets.
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
|
||||
*/
|
||||
async function account(session, action_type, args = {}) {
|
||||
if (!session.logged_in) throw new Error('You are not signed-in');
|
||||
async function account(session, action, args = {}) {
|
||||
if (!session.logged_in) throw new Error('You are not signed in');
|
||||
|
||||
const data = {};
|
||||
switch (action_type) {
|
||||
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 = arts.setting_item_id;
|
||||
data.settingItemId = args.setting_item_id;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
throw new Utils.InnertubeError('Invalid action', action);
|
||||
}
|
||||
|
||||
const response = await session.YTRequester.post(`/${action_type}`, JSON.stringify(data));
|
||||
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
|
||||
|
||||
const response = await session.YTRequester.post(`/${action}`, JSON.stringify(data)).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -143,12 +162,12 @@ async function account(session, action_type, args = {}) {
|
||||
* Accesses YouTube Music endpoints (/youtubei/v1/music/).
|
||||
*
|
||||
* @param {Innertube} session - A valid Innertube session.
|
||||
* @param {string} action_type - Type of action.
|
||||
* @param {string} action - Type of action.
|
||||
* @param {object} args - Action arguments.
|
||||
* @todo Implement more actions.
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
|
||||
*/
|
||||
async function music(session, action_type, args) {
|
||||
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;
|
||||
@@ -157,17 +176,17 @@ async function music(session, action_type, args) {
|
||||
|
||||
let data;
|
||||
|
||||
switch (action_type) {
|
||||
switch (action) {
|
||||
case 'get_search_suggestions':
|
||||
data.context = context;
|
||||
data.input = args.input || '';
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
throw new Utils.InnertubeError('Invalid action', action);
|
||||
}
|
||||
|
||||
const response = await session.YTMRequester.post(`/music/${action_type}`, JSON.stringify(data));
|
||||
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
|
||||
|
||||
const response = await session.YTMRequester.post(`/music/${action}`, JSON.stringify(data)).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -185,13 +204,15 @@ async function music(session, action_type, args) {
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
|
||||
*/
|
||||
async function search(session, client, args = {}) {
|
||||
if (!args.query) throw new Error('No query was provided');
|
||||
|
||||
const data = { context: session.context };
|
||||
switch (client) {
|
||||
case 'YOUTUBE':
|
||||
data.params = Utils.encodeFilter(args.options.period, args.options.duration, args.options.order);
|
||||
data.query = args.query;
|
||||
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
|
||||
@@ -199,17 +220,17 @@ async function search(session, client, args = {}) {
|
||||
context.client.originalUrl = Constants.URLS.YT_MUSIC;
|
||||
context.client.clientVersion = Constants.YTMUSIC_VERSION;
|
||||
context.client.clientName = 'WEB_REMIX';
|
||||
|
||||
|
||||
data.context = context;
|
||||
data.query = args.query;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
throw new Utils.InnertubeError('Invalid client', action);
|
||||
}
|
||||
|
||||
|
||||
const requester = client == 'YOUTUBE' && session.YTRequester || session.YTMRequester;
|
||||
const response = await requester.post('/search', JSON.stringify(data));
|
||||
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
|
||||
const response = await requester.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,
|
||||
@@ -222,33 +243,35 @@ async function search(session, client, args = {}) {
|
||||
* Interacts with YouTube's notification system.
|
||||
*
|
||||
* @param {Innertube} session - A valid Innertube session.
|
||||
* @param {string} action_type - Type of action.
|
||||
* @param {string} action - Type of action.
|
||||
* @param {object} args - Action arguments.
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
|
||||
*/
|
||||
async function notifications(session, action_type, args = {}) {
|
||||
if (!session.logged_in) throw new Error('You are not signed-in');
|
||||
async function notifications(session, action, args = {}) {
|
||||
if (!session.logged_in) throw new Error('You are not signed in');
|
||||
|
||||
const data = {};
|
||||
switch (action_type) {
|
||||
switch (action) {
|
||||
case 'modify_channel_preference':
|
||||
const pref_types = { PERSONALIZED: 1, ALL: 2, NONE: 3 };
|
||||
data.context = session.context;
|
||||
data.params = Utils.encodeNotificationPref(args.channel_id, pref_types[args.pref.toUpperCase()]);
|
||||
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.YTRequester.post(`/notification/${action_type}`, JSON.stringify(data)).catch((err) => err);
|
||||
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 };
|
||||
|
||||
const response = await session.YTRequester.post(`/notification/${action}`, JSON.stringify(data)).catch((err) => err);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
|
||||
if (action === 'modify_channel_preference') return { success: true, status_code: response.status };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -261,20 +284,20 @@ async function notifications(session, action_type, args = {}) {
|
||||
* Interacts with YouTube's livechat system.
|
||||
*
|
||||
* @param {Innertube} session - A valid Innertube session.
|
||||
* @param {string} action_type - Type of action.
|
||||
* @param {string} action - Type of action.
|
||||
* @param {object} args - Action arguments.
|
||||
* @returns {Promise.<{ success: boolean; data: object; message?: string }>}
|
||||
*/
|
||||
async function livechat(session, action_type, args = {}) {
|
||||
async function livechat(session, action, args = {}) {
|
||||
const data = {};
|
||||
switch (action_type) {
|
||||
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 = Utils.encodeMessageParams(args.channel_id, args.video_id);
|
||||
data.params = Proto.encodeMessageParams(args.channel_id, args.video_id);
|
||||
data.clientMessageId = `ytjs-${Uuid.v4()}`;
|
||||
data.richMessage = {
|
||||
textSegments: [{ text: args.text }]
|
||||
@@ -293,25 +316,13 @@ async function livechat(session, action_type, args = {}) {
|
||||
args.continuation && (data.continuation = args.continuation);
|
||||
break;
|
||||
default:
|
||||
throw new Utils.InnertubeError('Invalid action', action);
|
||||
}
|
||||
|
||||
const response = await session.YTRequester.post(`/${action_type}`, JSON.stringify(data)).catch((err) => err);
|
||||
if (response instanceof Error) return { success: false, message: response.message };
|
||||
|
||||
return { success: true, data: response.data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves video data.
|
||||
*
|
||||
* @param {Innertube} session - A valid Innertube session.
|
||||
* @param {object} args - Request arguments.
|
||||
* @returns {Promise.<object>} - Video data.
|
||||
*/
|
||||
async function getVideoInfo(session, args = {}) {
|
||||
const response = await session.YTRequester.post(`/player`, JSON.stringify(Constants.VIDEO_INFO_REQBODY(args.id, session.sts, session.context))).catch((err) => err);
|
||||
if (response instanceof Error) throw new Error(`Could not get video info: ${response.message}`);
|
||||
return response.data;
|
||||
const response = await session.YTRequester.post(`/${action}`, JSON.stringify(data)).catch((err) => err);
|
||||
if (response instanceof Error) return { success: false, message: response.message };
|
||||
|
||||
return { success: true, data: response.data };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -321,7 +332,7 @@ async function getVideoInfo(session, args = {}) {
|
||||
* @param {object} args - Continuation arguments.
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
|
||||
*/
|
||||
async function getContinuation(session, args = {}) {
|
||||
async function next(session, args = {}) {
|
||||
let data = { context: session.context };
|
||||
args.continuation_token && (data.continuation = args.continuation_token);
|
||||
|
||||
@@ -341,44 +352,17 @@ async function getContinuation(session, args = {}) {
|
||||
data.racyCheckOk = true;
|
||||
data.contentCheckOk = false;
|
||||
data.autonavState = 'STATE_NONE';
|
||||
data.playbackContext = {
|
||||
vis: 0,
|
||||
lactMilliseconds: '-1'
|
||||
}
|
||||
data.playbackContext = { vis: 0, lactMilliseconds: '-1' };
|
||||
data.captionsRequested = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const requester = args.ytmusic && session.YTMRequester || session.YTRequester;
|
||||
const response = await requester.post('/next', JSON.stringify(data));
|
||||
|
||||
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 - A valid innertube session
|
||||
* @param {string} query - Search query
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
|
||||
*/
|
||||
async function getYTSearchSuggestions(session, query) {
|
||||
const response = await Axios.get(`${Constants.URLS.YT_SUGGESTIONS}search?client=firefox&ds=yt&q=${query}`,
|
||||
Constants.DEFAULT_HEADERS(session)).catch((error) => error);
|
||||
const response = await requester.post('/next', JSON.stringify(data)).catch((error) => error);
|
||||
|
||||
if (response instanceof Error) return {
|
||||
success: false,
|
||||
status_code: response.response.status,
|
||||
success: false,
|
||||
status_code: response.response?.status || 0,
|
||||
message: response.message
|
||||
};
|
||||
|
||||
@@ -389,4 +373,41 @@ async function getYTSearchSuggestions(session, query) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { engage, browse, account, music, search, notifications, livechat, getVideoInfo, getContinuation, getYTSearchSuggestions };
|
||||
/**
|
||||
* Retrieves video data.
|
||||
*
|
||||
* @param {Innertube} session - A valid Innertube session.
|
||||
* @param {object} args - Request arguments.
|
||||
* @returns {Promise.<object>} - Video data.
|
||||
*/
|
||||
async function getVideoInfo(session, args = {}) {
|
||||
const response = await session.YTRequester.post(`/player`, JSON.stringify(Constants.VIDEO_INFO_REQBODY(args.id, session.sts, session.context))).catch((err) => err);
|
||||
if (response instanceof Error) throw new Error(`Could not get video info: ${response.message}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets search suggestions.
|
||||
*
|
||||
* @param {Innertube} session - A valid innertube session
|
||||
* @param {string} query - Search query
|
||||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
|
||||
*/
|
||||
async function getYTSearchSuggestions(session, query) {
|
||||
const response = await Axios.get(`${Constants.URLS.YT_SUGGESTIONS}search?client=firefox&ds=yt&q=${encodeURIComponent(query)}`,
|
||||
Constants.DEFAULT_HEADERS(session)).catch((error) => error);
|
||||
|
||||
if (response instanceof Error) return {
|
||||
success: false,
|
||||
status_code: response.status,
|
||||
message: response.message
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { engage, browse, account, music, search, notifications, livechat, getVideoInfo, next, getYTSearchSuggestions };
|
||||
@@ -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;
|
||||
@@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const Axios = require('axios');
|
||||
const Constants = require('./Constants');
|
||||
const Constants = require('../utils/Constants');
|
||||
const EventEmitter = require('events');
|
||||
const Uuid = require('uuid');
|
||||
|
||||
@@ -13,7 +13,7 @@ class OAuth extends EventEmitter {
|
||||
|
||||
this.oauth_code_url = `${Constants.URLS.YT_BASE}/o/oauth2/device/code`;
|
||||
this.oauth_token_url = `${Constants.URLS.YT_BASE}/o/oauth2/token`;
|
||||
|
||||
|
||||
this.model_name = Constants.OAUTH.MODEL_NAME;
|
||||
this.grant_type = Constants.OAUTH.GRANT_TYPE;
|
||||
this.scope = Constants.OAUTH.SCOPE;
|
||||
@@ -177,7 +177,7 @@ class OAuth extends EventEmitter {
|
||||
// Here we download the script and extract the necessary data to proceed with the auth flow.
|
||||
const url_body = this.auth_script_regex.exec(yttv_response.data)[1];
|
||||
const script_url = `${Constants.URLS.YT_BASE}/${url_body}`;
|
||||
|
||||
|
||||
const response = await Axios.get(script_url, Constants.DEFAULT_HEADERS).catch((error) => error);
|
||||
if (response instanceof Error) throw new Error(`Could not extract client identity: ${response.message}`);
|
||||
|
||||
@@ -196,4 +196,4 @@ class OAuth extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OAuth;
|
||||
module.exports = OAuth;
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
const Fs = require('fs');
|
||||
const Axios = require('axios');
|
||||
const Utils = require('./Utils');
|
||||
const Constants = require('./Constants');
|
||||
const Utils = require('../utils/Utils');
|
||||
const Constants = require('../utils/Constants');
|
||||
|
||||
class Player {
|
||||
constructor(session) {
|
||||
this.session = session;
|
||||
this.player_name = Utils.getStringBetweenStrings(this.session.player_url, '/player/', '/');
|
||||
this.tmp_cache_dir = __dirname.slice(0, -3) + 'cache';
|
||||
this.tmp_cache_dir = __dirname.slice(0, -8) + 'cache';
|
||||
}
|
||||
|
||||
async init() {
|
||||
@@ -28,7 +28,7 @@ class Player {
|
||||
// Caches the current player so we don't have to download it all the time.
|
||||
Fs.mkdirSync(this.tmp_cache_dir, { recursive: true });
|
||||
Fs.writeFileSync(`${this.tmp_cache_dir}/${this.player_name}.js`, response.data);
|
||||
} catch (err) { }
|
||||
} catch (err) {}
|
||||
|
||||
this.sig_decipher_sc = this.#getSigDecipherCode(response.data);
|
||||
this.ntoken_sc = this.#getNEncoder(response.data);
|
||||
@@ -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(''));
|
||||
}
|
||||
@@ -11,8 +11,8 @@ class SigDecipher {
|
||||
}
|
||||
|
||||
/**
|
||||
* Deciphers signature.
|
||||
*/
|
||||
* Deciphers signature.
|
||||
*/
|
||||
decipher() {
|
||||
const args = QueryString.parse(this.url);
|
||||
const functions = this.#getFunctions();
|
||||
255
lib/parser/index.js
Normal file
255
lib/parser/index.js
Normal file
@@ -0,0 +1,255 @@
|
||||
'use strict';
|
||||
|
||||
const Utils = require('../utils/Utils');
|
||||
const Actions = require('../core/Actions');
|
||||
const Constants = require('../utils/Constants');
|
||||
const YTDataItems = require('./youtube');
|
||||
const YTMusicDataItems = require('./ytmusic');
|
||||
|
||||
class Parser {
|
||||
constructor(session, data, args = {}) {
|
||||
this.session = session;
|
||||
this.data = data;
|
||||
this.args = args;
|
||||
}
|
||||
|
||||
parse() {
|
||||
const client = this.args.client;
|
||||
const data_type = this.args.data_type
|
||||
|
||||
let processed_data;
|
||||
|
||||
switch (client) {
|
||||
case 'YOUTUBE':
|
||||
processed_data = ({
|
||||
SEARCH: () => this.#processSearch(),
|
||||
PLAYLIST: () => this.#processPlaylist(),
|
||||
VIDEO_INFO: () => this.#processVideoInfo()
|
||||
})[data_type]()
|
||||
break;
|
||||
case 'YTMUSIC':
|
||||
processed_data = ({
|
||||
SEARCH: () => this.#processMusicSearch(),
|
||||
PLAYLIST: () => this.#processMusicPlaylist()
|
||||
})[data_type]();
|
||||
break;
|
||||
default:
|
||||
throw new Utils.InnertubeError('Invalid client');
|
||||
}
|
||||
|
||||
return processed_data;
|
||||
}
|
||||
|
||||
#processSearch() {
|
||||
const contents = Utils.findNode(this.data, 'contents', 'contents', 5);
|
||||
|
||||
const processed_data = {};
|
||||
|
||||
const parseItems = (contents) => {
|
||||
const content = contents[0].itemSectionRenderer.contents;
|
||||
|
||||
processed_data.query = content[0]?.showingResultsForRenderer?.originalQuery?.simpleText || this.args.query;
|
||||
processed_data.corrected_query = content[0]?.showingResultsForRenderer?.correctedQueryEndpoint?.searchEndpoint?.query || 'N/A';
|
||||
processed_data.estimated_results = parseInt(this.data.estimatedResults);
|
||||
|
||||
processed_data.videos = YTDataItems.VideoResultItem.parse(content);
|
||||
|
||||
processed_data.getContinuation = async () => {
|
||||
const citem = contents.find((item) => item.continuationItemRenderer);
|
||||
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
|
||||
|
||||
const response = await Actions.search(this.session, 'YOUTUBE', { ctoken });
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not get continuation', response);
|
||||
|
||||
const continuation_items = Utils.findNode(response.data, 'onResponseReceivedCommands', 'itemSectionRenderer', 4, false);
|
||||
return parseItems(continuation_items);
|
||||
};
|
||||
|
||||
return processed_data;
|
||||
}
|
||||
|
||||
return parseItems(contents);
|
||||
}
|
||||
|
||||
#processMusicSearch() {
|
||||
const tabs = Utils.findNode(this.data, 'contents', 'tabs').tabs;
|
||||
const contents = Utils.findNode(tabs, '0', 'contents', 5);
|
||||
|
||||
const did_you_mean_item = contents.find((content) => content.itemSectionRenderer);
|
||||
const did_you_mean_renderer = did_you_mean_item?.itemSectionRenderer.contents[0].didYouMeanRenderer;
|
||||
|
||||
const processed_data = {
|
||||
query: '',
|
||||
corrected_query: '',
|
||||
results: {}
|
||||
};
|
||||
|
||||
processed_data.query = this.args.query;
|
||||
processed_data.corrected_query = did_you_mean_renderer?.correctedQuery.runs.map((run) => run.text).join('') || 'N/A';
|
||||
|
||||
contents.forEach((content) => {
|
||||
const section = content?.musicShelfRenderer;
|
||||
if (section) {
|
||||
const section_title = section.title.runs[0].text;
|
||||
|
||||
const section_items = ({
|
||||
['Top result']: () => YTMusicDataItems.TopResultItem.parse(section.contents), // console.log(JSON.stringify(section.contents, null, 4)),
|
||||
['Songs']: () => YTMusicDataItems.SongResultItem.parse(section.contents),
|
||||
['Videos']: () => YTMusicDataItems.VideoResultItem.parse(section.contents),
|
||||
['Featured playlists']: () => YTMusicDataItems.PlaylistResultItem.parse(section.contents),
|
||||
['Community playlists']: () => YTMusicDataItems.PlaylistResultItem.parse(section.contents),
|
||||
['Artists']: () => YTMusicDataItems.ArtistResultItem.parse(section.contents),
|
||||
['Albums']: () => YTMusicDataItems.AlbumResultItem.parse(section.contents)
|
||||
})[section_title]();
|
||||
|
||||
processed_data.results[section_title.replace(/ /g, '_').toLowerCase()] = section_items;
|
||||
}
|
||||
});
|
||||
|
||||
return processed_data;
|
||||
}
|
||||
|
||||
#processPlaylist() {
|
||||
const details = this.data.sidebar.playlistSidebarRenderer.items[0];
|
||||
|
||||
const metadata = {
|
||||
title: this.data.metadata.playlistMetadataRenderer.title,
|
||||
description: details.playlistSidebarPrimaryInfoRenderer.description.simpleText || 'N/A',
|
||||
total_items: details.playlistSidebarPrimaryInfoRenderer.stats[0].runs[0].text,
|
||||
last_updated: details.playlistSidebarPrimaryInfoRenderer.stats[2].runs[1].text,
|
||||
views: details.playlistSidebarPrimaryInfoRenderer.stats[1].simpleText
|
||||
}
|
||||
|
||||
const list = Utils.findNode(this.data, 'contents', 'contents', 13, false);
|
||||
const items = YTDataItems.PlaylistItem.parse(list.contents);
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
#processMusicPlaylist() {
|
||||
const details = this.data.header.musicDetailHeaderRenderer;
|
||||
|
||||
const metadata = {
|
||||
title: details?.title?.runs[0].text,
|
||||
description: details?.description?.runs?.map((run) => run.text).join('') || 'N/A',
|
||||
total_items: parseInt(details?.secondSubtitle?.runs[0].text.match(/\d+/g)),
|
||||
duration: details?.secondSubtitle?.runs[2].text,
|
||||
year: details?.subtitle?.runs[4].text
|
||||
};
|
||||
|
||||
const contents = this.data.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents;
|
||||
const playlist_content = contents[0].musicPlaylistShelfRenderer.contents;
|
||||
|
||||
const items = YTMusicDataItems.PlaylistItem.parse(playlist_content);
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Video data is parsed dynamically, so if youtube decides to add something new we won't have to change anything here.
|
||||
*/
|
||||
#processVideoInfo() {
|
||||
const playability_status = this.data.playabilityStatus;
|
||||
|
||||
if (playability_status.status == 'ERROR')
|
||||
throw new Error(`Could not retrieve video details: ${playability_status.status} - ${playability_status.reason}`);
|
||||
|
||||
const details = this.data.videoDetails;
|
||||
const microformat = this.data.microformat.playerMicroformatRenderer;
|
||||
const streaming_data = this.data.streamingData;
|
||||
|
||||
const mf_raw_data = Object.entries(microformat);
|
||||
const dt_raw_data = Object.entries(details);
|
||||
|
||||
const processed_data = {
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
thumbnail: [],
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
// Extracts most of the metadata
|
||||
mf_raw_data.forEach((entry) => {
|
||||
const key = Utils.camelToSnake(entry[0]);
|
||||
if (Constants.METADATA_KEYS.includes(key)) {
|
||||
key == 'view_count' && (processed_data.metadata[key] = parseInt(entry[1])) ||
|
||||
key == 'owner_profile_url' && (processed_data.metadata.channel_url = entry[1]) ||
|
||||
key == 'owner_channel_name' && (processed_data.metadata.channel_name = entry[1]) ||
|
||||
(processed_data.metadata[key] = entry[1]);
|
||||
} else {
|
||||
processed_data[key] = entry[1];
|
||||
}
|
||||
});
|
||||
|
||||
// Extracts extra details
|
||||
dt_raw_data.forEach((entry) => {
|
||||
const key = Utils.camelToSnake(entry[0]);
|
||||
if (Constants.BLACKLISTED_KEYS.includes(key)) return;
|
||||
if (Constants.METADATA_KEYS.includes(key)) {
|
||||
key == 'view_count' && (processed_data.metadata[key] = parseInt(entry[1])) ||
|
||||
(processed_data.metadata[key] = entry[1]);
|
||||
} else {
|
||||
key == 'short_description' && (processed_data.description = entry[1]) ||
|
||||
key == 'thumbnail' && (processed_data.thumbnail = entry[1].thumbnails.slice(-1)[0]) ||
|
||||
key == 'video_id' && (processed_data.id = entry[1]) ||
|
||||
(processed_data[key] = entry[1]);
|
||||
}
|
||||
});
|
||||
|
||||
// Data continuation is only required for getDetails()
|
||||
if (this.data.continuation) {
|
||||
const primary_info_renderer = this.data.continuation.contents.twoColumnWatchNextResults
|
||||
.results.results.contents.find((item) => item.videoPrimaryInfoRenderer).videoPrimaryInfoRenderer;
|
||||
|
||||
const secondary_info_renderer = this.data.continuation.contents.twoColumnWatchNextResults
|
||||
.results.results.contents.find((item) => item.videoSecondaryInfoRenderer).videoSecondaryInfoRenderer;
|
||||
|
||||
const like_btn = primary_info_renderer.videoActions.menuRenderer
|
||||
.topLevelButtons.find((item) => item.toggleButtonRenderer.defaultIcon.iconType == 'LIKE');
|
||||
|
||||
const dislike_btn = primary_info_renderer.videoActions.menuRenderer
|
||||
.topLevelButtons.find((item) => item.toggleButtonRenderer.defaultIcon.iconType == 'DISLIKE');
|
||||
|
||||
const notification_toggle_btn = secondary_info_renderer.subscribeButton.subscribeButtonRenderer
|
||||
?.notificationPreferenceButton?.subscriptionNotificationToggleButtonRenderer;
|
||||
|
||||
// These will always be false if logged out.
|
||||
processed_data.metadata.is_liked = like_btn.toggleButtonRenderer.isToggled;
|
||||
processed_data.metadata.is_disliked = dislike_btn.toggleButtonRenderer.isToggled;
|
||||
processed_data.metadata.is_subscribed = secondary_info_renderer.subscribeButton.subscribeButtonRenderer?.subscribed || false;
|
||||
|
||||
processed_data.metadata.subscriber_count = secondary_info_renderer.owner.videoOwnerRenderer?.subscriberCountText?.simpleText || 'N/A';
|
||||
processed_data.metadata.current_notification_preference = notification_toggle_btn?.states.find((state) => state.stateId == notification_toggle_btn.currentStateId)
|
||||
.state.buttonRenderer.icon.iconType || 'N/A';
|
||||
|
||||
// Simpler version of publish_date
|
||||
processed_data.metadata.publish_date_text = primary_info_renderer.dateText.simpleText;
|
||||
|
||||
// Only parse like count if it's enabled
|
||||
if (processed_data.metadata.allow_ratings) {
|
||||
processed_data.metadata.likes = {
|
||||
count: parseInt(like_btn.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, '')),
|
||||
short_count_text: like_btn.toggleButtonRenderer.defaultText.simpleText
|
||||
};
|
||||
}
|
||||
|
||||
processed_data.metadata.owner_badges = secondary_info_renderer.owner.videoOwnerRenderer?.badges?.map((badge) => badge.metadataBadgeRenderer.tooltip) || [];
|
||||
}
|
||||
|
||||
streaming_data && streaming_data.adaptiveFormats &&
|
||||
(processed_data.metadata.available_qualities = [...new Set(streaming_data.adaptiveFormats.filter(v => v.qualityLabel)
|
||||
.map(v => v.qualityLabel).sort((a, b) => +a.replace(/\D/gi, '') - +b.replace(/\D/gi, '')))]) ||
|
||||
(processed_data.metadata.available_qualities = []);
|
||||
|
||||
return processed_data;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Parser;
|
||||
6
lib/parser/youtube/index.js
Normal file
6
lib/parser/youtube/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
const VideoResultItem = require('./search/VideoResultItem');
|
||||
const PlaylistItem = require('./others/PlaylistItem');
|
||||
|
||||
module.exports = { VideoResultItem, PlaylistItem };
|
||||
26
lib/parser/youtube/others/PlaylistItem.js
Normal file
26
lib/parser/youtube/others/PlaylistItem.js
Normal 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;
|
||||
43
lib/parser/youtube/search/VideoResultItem.js
Normal file
43
lib/parser/youtube/search/VideoResultItem.js
Normal 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;
|
||||
11
lib/parser/ytmusic/index.js
Normal file
11
lib/parser/ytmusic/index.js
Normal file
@@ -0,0 +1,11 @@
|
||||
'use strict';
|
||||
|
||||
const SongResultItem = require('./search/SongResultItem');
|
||||
const VideoResultItem = require('./search/VideoResultItem');
|
||||
const AlbumResultItem = require('./search/AlbumResultItem');
|
||||
const ArtistResultItem = require('./search/ArtistResultItem');
|
||||
const PlaylistResultItem = require('./search/PlaylistResultItem');
|
||||
const TopResultItem = require('./search/TopResultItem');
|
||||
const PlaylistItem = require('./others/PlaylistItem');
|
||||
|
||||
module.exports = { SongResultItem, VideoResultItem, AlbumResultItem, ArtistResultItem, PlaylistResultItem, TopResultItem, PlaylistItem };
|
||||
28
lib/parser/ytmusic/others/PlaylistItem.js
Normal file
28
lib/parser/ytmusic/others/PlaylistItem.js
Normal 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;
|
||||
21
lib/parser/ytmusic/search/AlbumResultItem.js
Normal file
21
lib/parser/ytmusic/search/AlbumResultItem.js
Normal 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;
|
||||
19
lib/parser/ytmusic/search/ArtistResultItem.js
Normal file
19
lib/parser/ytmusic/search/ArtistResultItem.js
Normal 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;
|
||||
23
lib/parser/ytmusic/search/PlaylistResultItem.js
Normal file
23
lib/parser/ytmusic/search/PlaylistResultItem.js
Normal 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;
|
||||
22
lib/parser/ytmusic/search/SongResultItem.js
Normal file
22
lib/parser/ytmusic/search/SongResultItem.js
Normal 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;
|
||||
32
lib/parser/ytmusic/search/TopResultItem.js
Normal file
32
lib/parser/ytmusic/search/TopResultItem.js
Normal file
@@ -0,0 +1,32 @@
|
||||
'use strict';
|
||||
|
||||
const SongResultItem = require('./SongResultItem');
|
||||
const VideoResultItem = require('./VideoResultItem');
|
||||
const AlbumResultItem = require('./AlbumResultItem');
|
||||
const ArtistResultItem = require('./ArtistResultItem');
|
||||
const PlaylistResultItem = require('./PlaylistResultItem');
|
||||
|
||||
class TopResultItem {
|
||||
static parse(data) {
|
||||
return data.map((item) => {
|
||||
const list_item = item.musicResponsiveListItemRenderer;
|
||||
|
||||
const runs = list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs;
|
||||
const type = runs[0].text.toLowerCase();
|
||||
|
||||
const parsed_item = ({
|
||||
playlist: () => PlaylistResultItem.parseItem(item),
|
||||
song: () => SongResultItem.parseItem(item),
|
||||
video: () => VideoResultItem.parseItem(item),
|
||||
artist: () => ArtistResultItem.parseItem(item),
|
||||
album: () => ArtistResultItem.parseItem(item)
|
||||
}[type])();
|
||||
|
||||
parsed_item.type = type;
|
||||
|
||||
return parsed_item;
|
||||
}).filter((item) => item);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TopResultItem;
|
||||
22
lib/parser/ytmusic/search/VideoResultItem.js
Normal file
22
lib/parser/ytmusic/search/VideoResultItem.js
Normal 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;
|
||||
133
lib/proto/index.js
Normal file
133
lib/proto/index.js
Normal file
@@ -0,0 +1,133 @@
|
||||
'use strict';
|
||||
|
||||
const Fs = require('fs');
|
||||
const Proto = require('protons');
|
||||
|
||||
/**
|
||||
* Encodes advanced search filters.
|
||||
*
|
||||
* @param {string} period - Period in which a video is uploaded: any | hour | day | week | month | year
|
||||
* @param {string} duration - The duration of a video: any | short | long
|
||||
* @param {string} order - The order of the search results: relevance | rating | age | views
|
||||
* @returns {string}
|
||||
*/
|
||||
function encodeSearchFilter(period, duration, order) {
|
||||
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/youtube.proto`));
|
||||
|
||||
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'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes livestream message parameters.
|
||||
*
|
||||
* @param {string} channel_id - The id of the channel hosting the livestream.
|
||||
* @param {string} video_id - The id of the livestream.
|
||||
* @returns {string}
|
||||
*/
|
||||
function encodeMessageParams(channel_id, video_id) {
|
||||
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/youtube.proto`));
|
||||
|
||||
const buf = youtube_proto.LiveMessageParams.encode({
|
||||
params: {
|
||||
ids: { channel_id, video_id }
|
||||
},
|
||||
number_0: 1,
|
||||
number_1: 4
|
||||
});
|
||||
|
||||
return Buffer.from(encodeURIComponent(Buffer.from(buf).toString('base64'))).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes comment parameters.
|
||||
*
|
||||
* @param {string} video_id - The id of the video you're commenting on.
|
||||
* @returns {string}
|
||||
*/
|
||||
function encodeCommentParams(video_id) {
|
||||
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/youtube.proto`));
|
||||
|
||||
const buf = youtube_proto.CreateCommentParams.encode({
|
||||
video_id,
|
||||
params: { index: 0 },
|
||||
number: 7
|
||||
});
|
||||
|
||||
return encodeURIComponent(Buffer.from(buf).toString('base64'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes comment reply parameters.
|
||||
*
|
||||
* @param {string} comment_id - The id of the comment.
|
||||
* @param {string} video_id - The id of the video you're commenting on.
|
||||
* @returns {string}
|
||||
*/
|
||||
function encodeCommentReplyParams(comment_id, video_id) {
|
||||
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/youtube.proto`));
|
||||
|
||||
const buf = youtube_proto.CreateCommentReplyParams.encode({
|
||||
video_id, comment_id,
|
||||
params: { unk_num: 0 },
|
||||
unk_num: 7
|
||||
});
|
||||
|
||||
return encodeURIComponent(Buffer.from(buf).toString('base64'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes comment action parameters (liking, disliking, reporting a comment etc).
|
||||
*
|
||||
* @param {string} type - Type of action.
|
||||
* @param {string} comment_id - The id of the comment.
|
||||
* @param {string} video_id - The id of the video you're commenting on.
|
||||
* @param {string} channel_id - The id of the channel.
|
||||
* @returns {string}
|
||||
*/
|
||||
function encodeCommentActionParams(type, comment_id, video_id, channel_id) {
|
||||
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/youtube.proto`));
|
||||
|
||||
const buf = youtube_proto.PeformCommentActionParams.encode({
|
||||
type, comment_id, channel_id, video_id,
|
||||
unk_num: 2, unk_num_1: 0, unk_num_2: 0,
|
||||
unk_num_3: "0", unk_num_4: 0,
|
||||
unk_num_5: 12, unk_num_6: 0,
|
||||
});
|
||||
|
||||
return encodeURIComponent(Buffer.from(buf).toString('base64'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes notification preferences.
|
||||
*
|
||||
* @param {string} channel_id - The id of the channel.
|
||||
* @param {string} index - The index of the preference id.
|
||||
* @returns {string}
|
||||
*/
|
||||
function encodeNotificationPref(channel_id, index) {
|
||||
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/youtube.proto`));
|
||||
|
||||
const buf = youtube_proto.NotificationPreferences.encode({
|
||||
channel_id,
|
||||
pref_id: { index },
|
||||
number_0: 0,
|
||||
number_1: 4
|
||||
});
|
||||
|
||||
return encodeURIComponent(Buffer.from(buf).toString('base64'));
|
||||
}
|
||||
|
||||
module.exports = { encodeMessageParams, encodeCommentParams, encodeCommentReplyParams, encodeCommentActionParams, encodeNotificationPref, encodeSearchFilter };
|
||||
@@ -1,44 +1,75 @@
|
||||
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 {
|
||||
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 CreateCommentReplyParams {
|
||||
string video_id = 2;
|
||||
string comment_id = 4;
|
||||
|
||||
message UnknownParams {
|
||||
int32 unk_num = 1;
|
||||
}
|
||||
UnknownParams params = 5;
|
||||
|
||||
int32 unk_num = 10;
|
||||
}
|
||||
|
||||
message PeformCommentActionParams {
|
||||
int32 type = 1;
|
||||
int32 unk_num = 2;
|
||||
|
||||
string comment_id = 3;
|
||||
string video_id = 5;
|
||||
|
||||
int32 unk_num_1 = 6;
|
||||
int32 unk_num_2 = 7;
|
||||
|
||||
string unk_num_3 = 9;
|
||||
|
||||
int32 unk_num_4 = 10;
|
||||
int32 unk_num_5 = 21;
|
||||
|
||||
string channel_id = 23;
|
||||
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;
|
||||
}
|
||||
@@ -34,8 +34,7 @@ module.exports = {
|
||||
'Accept': 'text/html',
|
||||
'Accept-Language': 'en-US,en',
|
||||
'Accept-Encoding': 'gzip'
|
||||
},
|
||||
|
||||
}
|
||||
};
|
||||
},
|
||||
STREAM_HEADERS: {
|
||||
@@ -62,13 +61,13 @@ module.exports = {
|
||||
'x-origin': origin,
|
||||
'origin': origin
|
||||
};
|
||||
|
||||
|
||||
if (info.session.logged_in) {
|
||||
|
||||
|
||||
headers.Cookie = info.session.cookie;
|
||||
headers.authorization = info.session.cookie.length && info.session.auth_apisid || `Bearer ${info.session.access_token}`;
|
||||
}
|
||||
|
||||
|
||||
return headers
|
||||
},
|
||||
VIDEO_INFO_REQBODY: (id, sts, context) => {
|
||||
@@ -121,6 +120,10 @@ module.exports = {
|
||||
NORMAL: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'.split(''),
|
||||
REVERSE: '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'.split('')
|
||||
},
|
||||
NTOKEN_REGEX: {
|
||||
CALLS: /c\[(.*?)\]\((.+?)\)/g,
|
||||
PLACEHOLDERS: /c\[(.*?)\]=c/g,
|
||||
},
|
||||
FUNCS_REGEX: /d\.push\(e\)|d\.reverse\(\)|d\[0\]\)\[0\]\)|f=d\[0];d\[0\]|d\.length;d\.splice\(e,1\)|function\(\){for\(var|function\(d,e,f\){var|function\(d\){for\(var|reverse\(\)\.forEach|unshift\(d\.pop\(\)\)|function\(d,e\){for\(var f/,
|
||||
FUNCS: {
|
||||
PUSH: 'd.push(e)',
|
||||
@@ -1,9 +1,56 @@
|
||||
'use strict';
|
||||
|
||||
const Fs = require('fs');
|
||||
const Proto = require('protons');
|
||||
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.
|
||||
@@ -39,23 +86,6 @@ function generateSidAuth(sid) {
|
||||
return ['SAPISIDHASH', [timestamp, gen_hash].join('_')].join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a string between two delimiters.
|
||||
*
|
||||
* @param {string} data - The data.
|
||||
* @param {string} start_string - Start string.
|
||||
* @param {string} end_string - End string.
|
||||
*/
|
||||
function getStringBetweenStrings(data, start_string, end_string) {
|
||||
const regex = new RegExp(`${escapeStringRegexp(start_string)}(.*?)${escapeStringRegexp(end_string)}`, "s");
|
||||
const match = data.match(regex);
|
||||
return match ? match[1] : undefined;
|
||||
}
|
||||
|
||||
function escapeStringRegexp(string) {
|
||||
return string.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&").replace(/-/g, "\\x2d");
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts time (h:m:s) to seconds.
|
||||
*
|
||||
@@ -81,99 +111,6 @@ 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
|
||||
*
|
||||
@@ -190,4 +127,7 @@ function refineNTokenData(data) {
|
||||
.replace(/""/g, '').replace(/length]\)}"/g, 'length])}');
|
||||
}
|
||||
|
||||
module.exports = { getRandomUserAgent, generateSidAuth, getStringBetweenStrings, camelToSnake, timeToSeconds, encodeMessageParams, encodeCommentParams, encodeNotificationPref, encodeFilter, refineNTokenData };
|
||||
const errors = { UnavailableContentError, ParsingError, DownloadError, InnertubeError, MissingParamError, NoStreamingDataError };
|
||||
const functions = { findNode, getRandomUserAgent, generateSidAuth, getStringBetweenStrings, camelToSnake, timeToSeconds, refineNTokenData };
|
||||
|
||||
module.exports = { ...functions, ...errors };
|
||||
33
package-lock.json
generated
33
package-lock.json
generated
@@ -1,19 +1,23 @@
|
||||
{
|
||||
"name": "youtubei.js",
|
||||
"version": "1.3.7",
|
||||
"version": "1.4.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "youtubei.js",
|
||||
"version": "1.3.7",
|
||||
"version": "1.4.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^0.21.4",
|
||||
"flat": "^5.0.2",
|
||||
"protons": "^2.0.3",
|
||||
"user-agents": "^1.0.778",
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/luanrt"
|
||||
}
|
||||
@@ -55,6 +59,14 @@
|
||||
"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.9",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz",
|
||||
@@ -130,9 +142,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/user-agents": {
|
||||
"version": "1.0.963",
|
||||
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.963.tgz",
|
||||
"integrity": "sha512-TzX6aH2dfEbPsTcQk1ZKYCKXU9VUIZy8Vyaiml2pdraKRB1TC6hcPx38M5JLGZicNXyuVInsbSW+xQ30etZmyw==",
|
||||
"version": "1.0.984",
|
||||
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.984.tgz",
|
||||
"integrity": "sha512-gFYUg9GRrUA5LPKBa+K2K6jML3VPseVxm2TzhfTMVpLuxYZGm4qM8egSfQ7DV8X4DTNwECHAhlwv6JWZnIsCHQ==",
|
||||
"dependencies": {
|
||||
"dot-json": "^1.2.2",
|
||||
"lodash.clonedeep": "^4.5.0"
|
||||
@@ -181,6 +193,11 @@
|
||||
"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.9",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz",
|
||||
@@ -242,9 +259,9 @@
|
||||
}
|
||||
},
|
||||
"user-agents": {
|
||||
"version": "1.0.963",
|
||||
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.963.tgz",
|
||||
"integrity": "sha512-TzX6aH2dfEbPsTcQk1ZKYCKXU9VUIZy8Vyaiml2pdraKRB1TC6hcPx38M5JLGZicNXyuVInsbSW+xQ30etZmyw==",
|
||||
"version": "1.0.984",
|
||||
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.984.tgz",
|
||||
"integrity": "sha512-gFYUg9GRrUA5LPKBa+K2K6jML3VPseVxm2TzhfTMVpLuxYZGm4qM8egSfQ7DV8X4DTNwECHAhlwv6JWZnIsCHQ==",
|
||||
"requires": {
|
||||
"dot-json": "^1.2.2",
|
||||
"lodash.clonedeep": "^4.5.0"
|
||||
|
||||
26
package.json
26
package.json
@@ -1,20 +1,24 @@
|
||||
{
|
||||
"name": "youtubei.js",
|
||||
"version": "1.3.7",
|
||||
"description": "An object-oriented library that allows you to search, get detailed info about videos, subscribe, unsubscribe, like, dislike, comment, download videos and much more!",
|
||||
"version": "1.4.0",
|
||||
"description": "A full-featured library that allows you to get detailed info about any video, subscribe, unsubscribe, like, dislike, comment, search, download videos/music and much more!",
|
||||
"main": "index.js",
|
||||
"author": "LuanRT <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",
|
||||
"directories": {
|
||||
"example": "examples",
|
||||
"lib": "lib"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^0.21.4",
|
||||
"flat": "^5.0.2",
|
||||
"protons": "^2.0.3",
|
||||
"user-agents": "^1.0.778",
|
||||
"uuid": "^8.3.2"
|
||||
@@ -23,6 +27,10 @@
|
||||
"type": "git",
|
||||
"url": "git+https//github.com/LuanRT/YouTube.js.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/LuanRT/YouTube.js/issues"
|
||||
},
|
||||
"homepage": "https://github.com/LuanRT/YouTube.js#readme",
|
||||
"keywords": [
|
||||
"yt",
|
||||
"ytdl",
|
||||
@@ -32,17 +40,13 @@
|
||||
"youtube-downloader",
|
||||
"innertube",
|
||||
"innertubeapi",
|
||||
"livechat",
|
||||
"downloader",
|
||||
"livechat",
|
||||
"dislike",
|
||||
"search",
|
||||
"comment",
|
||||
"like",
|
||||
"api",
|
||||
"dl"
|
||||
],
|
||||
"bugs": {
|
||||
"url": "https://github.com/LuanRT/YouTube.js/issues"
|
||||
},
|
||||
"homepage": "https://github.com/LuanRT/YouTube.js#readme"
|
||||
]
|
||||
}
|
||||
|
||||
136
test/index.js
136
test/index.js
@@ -1,61 +1,75 @@
|
||||
'use strict';
|
||||
|
||||
const Fs = require('fs');
|
||||
const Innertube = require('..');
|
||||
const NToken = require('../lib/NToken');
|
||||
const SigDecipher = require('../lib/Sig');
|
||||
const Constants = require('./constants');
|
||||
|
||||
let failed_tests = 0;
|
||||
|
||||
async function performTests() {
|
||||
const youtube = await new Innertube().catch((error) => error);
|
||||
assert(!(youtube instanceof Error), `should retrieve Innertube configuration data`, youtube);
|
||||
|
||||
if (!(youtube instanceof Error)) {
|
||||
const search = await youtube.search('Carl Sagan - Documentary').catch((error) => error);
|
||||
assert(!(search instanceof Error) && search.videos.length >= 1, `should search videos`, search);
|
||||
|
||||
const details = await youtube.getDetails(Constants.test_video_id).catch((error) => error);
|
||||
assert(!(details instanceof Error), `should retrieve details for ${Constants.test_video_id}`, details);
|
||||
|
||||
const comments = await youtube.getComments(Constants.test_video_id).catch((error) => error);
|
||||
assert(!(comments instanceof Error), `should retrieve comments for ${Constants.test_video_id}`, comments);
|
||||
|
||||
const video = await downloadVideo(Constants.test_video_id, youtube).catch((error) => error);
|
||||
assert(!(video instanceof Error), `should download video (${Constants.test_video_id})`, video);
|
||||
}
|
||||
|
||||
|
||||
const n_token = new NToken(Constants.n_scramble_sc).transform(Constants.original_ntoken);
|
||||
assert(n_token == Constants.expected_ntoken, `should transform n token into ${Constants.expected_ntoken}`, n_token);
|
||||
|
||||
const transformed_url = new SigDecipher(Constants.test_url, { sig_decipher_sc: Constants.sig_decipher_sc }).decipher();
|
||||
assert(transformed_url == Constants.expected_url, `should correctly decipher signature`, transformed_url);
|
||||
|
||||
if (failed_tests > 0)
|
||||
throw new Error('Some tests have failed');
|
||||
}
|
||||
|
||||
function downloadVideo(id, youtube) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let got_video_info = false;
|
||||
const stream = youtube.download(id, { type: 'videoandaudio' });
|
||||
stream.pipe(Fs.createWriteStream(`./${id}.mp4`));
|
||||
stream.on('end', () => Fs.existsSync(`./${id}.mp4`) && got_video_info && resolve() || reject());
|
||||
stream.on('info', () => got_video_info = true);
|
||||
stream.on('error', (err) => reject(err));
|
||||
});
|
||||
}
|
||||
|
||||
function assert(outcome, description, data) {
|
||||
const pass_fail = outcome ? 'pass' : 'fail';
|
||||
|
||||
console.info(pass_fail, ':', description);
|
||||
!outcome && (failed_tests += 1);
|
||||
!outcome && console.error('Error: ', data);
|
||||
|
||||
return outcome;
|
||||
}
|
||||
|
||||
performTests();
|
||||
'use strict';
|
||||
|
||||
const Fs = require('fs');
|
||||
const Innertube = require('..');
|
||||
const NToken = require('../lib/deciphers/NToken');
|
||||
const SigDecipher = require('../lib/deciphers/Sig');
|
||||
const Constants = require('./constants');
|
||||
|
||||
let failed_tests = 0;
|
||||
|
||||
async function performTests() {
|
||||
const youtube = await new Innertube().catch((error) => error);
|
||||
assert(!(youtube instanceof Error), `should retrieve Innertube configuration data`, youtube);
|
||||
|
||||
if (!(youtube instanceof Error)) {
|
||||
const homefeed = await youtube.getHomeFeed();
|
||||
assert(!(homefeed instanceof Error), `should retrieve recommendations`, homefeed);
|
||||
|
||||
const ytsearch = await youtube.search('Carl Sagan - Documentary').catch((error) => error);
|
||||
assert(!(ytsearch instanceof Error) && ytsearch.videos.length, `should search on YouTube`, ytsearch);
|
||||
|
||||
const ytmsearch = await youtube.search('Logic - Obediently Yours', { client: 'YTMUSIC' }).catch((error) => error);
|
||||
assert(!(ytmsearch instanceof Error), `should search on YouTube Music`, ytmsearch);
|
||||
|
||||
const details = await youtube.getDetails(Constants.test_video_id).catch((error) => error);
|
||||
assert(!(details instanceof Error), `should retrieve details for ${Constants.test_video_id}`, details);
|
||||
|
||||
const comments = await youtube.getComments(Constants.test_video_id).catch((error) => error);
|
||||
assert(!(comments instanceof Error), `should retrieve comments for ${Constants.test_video_id}`, comments);
|
||||
|
||||
const ytplaylist = await youtube.getPlaylist(ytmsearch.results.community_playlists[0].id, { client: 'YOUTUBE' });
|
||||
assert(!(ytplaylist instanceof Error), `should retrieve and parse playlist with YouTube`, ytplaylist);
|
||||
|
||||
const ytmplaylist = await youtube.getPlaylist(ytmsearch.results.community_playlists[0].id, { client: 'YTMUSIC' });
|
||||
assert(!(ytmplaylist instanceof Error), `should retrieve and parse playlist with YouTube Music`, ytmplaylist);
|
||||
|
||||
const lyrics = await youtube.getLyrics(ytmsearch.results.songs[0].id);
|
||||
assert(!(lyrics instanceof Error), `should retrieve song lyrics`, lyrics);
|
||||
|
||||
const video = await downloadVideo(Constants.test_video_id, youtube).catch((error) => error);
|
||||
assert(!(video instanceof Error), `should download video (${Constants.test_video_id})`, video);
|
||||
}
|
||||
|
||||
const n_token = new NToken(Constants.n_scramble_sc, Constants.original_ntoken).transform();
|
||||
assert(n_token == Constants.expected_ntoken, `should transform n token into ${Constants.expected_ntoken}`, n_token);
|
||||
|
||||
const transformed_url = new SigDecipher(Constants.test_url, { sig_decipher_sc: Constants.sig_decipher_sc }).decipher();
|
||||
assert(transformed_url == Constants.expected_url, `should correctly decipher signature`, transformed_url);
|
||||
|
||||
if (failed_tests > 0)
|
||||
throw new Error('Some tests have failed');
|
||||
}
|
||||
|
||||
function downloadVideo(id, youtube) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let got_video_info = false;
|
||||
const stream = youtube.download(id, { type: 'videoandaudio' });
|
||||
stream.pipe(Fs.createWriteStream(`./${id}.mp4`));
|
||||
stream.on('end', () => Fs.existsSync(`./${id}.mp4`) && got_video_info && resolve() || reject());
|
||||
stream.on('info', () => got_video_info = true);
|
||||
stream.on('error', (err) => reject(err));
|
||||
});
|
||||
}
|
||||
|
||||
function assert(outcome, description, data) {
|
||||
const pass_fail = outcome ? 'pass' : 'fail';
|
||||
|
||||
console.info(pass_fail, ':', description);
|
||||
!outcome && (failed_tests += 1);
|
||||
!outcome && console.error('Error: ', data);
|
||||
|
||||
return outcome;
|
||||
}
|
||||
|
||||
performTests();
|
||||
|
||||
Reference in New Issue
Block a user