mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-07-02 21:52:48 +00:00
feat: add acc settings and alternative to download
This commit is contained in:
@@ -7,7 +7,8 @@ module.exports = {
|
||||
YT_BASE_URL: 'https://www.youtube.com',
|
||||
YT_MUSIC_URL: 'https://music.youtube.com',
|
||||
YT_MOBILE_URL: 'https://m.youtube.com',
|
||||
YT_WATCH_PAGE: 'https://m.youtube.com/watch'
|
||||
YT_WATCH_PAGE: 'https://m.youtube.com/watch',
|
||||
YT_SUGGESTIONS: 'https://suggestqueries.google.com/complete/'
|
||||
},
|
||||
OAUTH: {
|
||||
SCOPE: 'http://gdata.youtube.com https://www.googleapis.com/auth/youtube-paid-content',
|
||||
@@ -95,6 +96,7 @@ module.exports = {
|
||||
videoId: id
|
||||
};
|
||||
},
|
||||
YTMUSIC_VERSION: '1.20211213.00.00',
|
||||
METADATA_KEYS: [
|
||||
'embed', 'view_count', 'average_rating',
|
||||
'length_seconds', 'channel_id', 'channel_url',
|
||||
@@ -108,6 +110,19 @@ module.exports = {
|
||||
'is_owner_viewing', 'is_unplugged_corpus',
|
||||
'is_crawlable', 'allow_ratings', 'author'
|
||||
],
|
||||
ACCOUNT_SETTINGS: {
|
||||
// Notifications
|
||||
SUBSCRIPTIONS: 'NOTIFICATION_SUBSCRIPTION_NOTIFICATIONS',
|
||||
RECOMMENDED_VIDEOS: 'NOTIFICATION_RECOMMENDATION_WEB_CONTROL',
|
||||
CHANNEL_ACTIVITY: 'NOTIFICATION_COMMENT_WEB_CONTROL',
|
||||
COMMENT_REPLIES: 'NOTIFICATION_COMMENT_REPLY_OTHER_WEB_CONTROL',
|
||||
USER_MENTION: 'NOTIFICATION_USER_MENTION_WEB_CONTROL',
|
||||
SHARED_CONTENT: 'NOTIFICATION_RETUBING_WEB_CONTROL',
|
||||
|
||||
// Privacy
|
||||
PLAYLISTS_PRIVACY: 'PRIVACY_DISCOVERABLE_SAVED_PLAYLISTS',
|
||||
SUBSCRIPTIONS_PRIVACY: 'PRIVACY_DISCOVERABLE_SUBSCRIPTIONS'
|
||||
},
|
||||
BASE64_DIALECT: {
|
||||
NORMAL: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'.split(''),
|
||||
REVERSE: '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'.split('')
|
||||
|
||||
548
lib/Innertube.js
548
lib/Innertube.js
@@ -15,9 +15,21 @@ const EventEmitter = require('events');
|
||||
const CancelToken = Axios.CancelToken;
|
||||
|
||||
class Innertube {
|
||||
#player;
|
||||
#retry_count;
|
||||
|
||||
/**
|
||||
* ```js
|
||||
* const Innertube = require('youtubei.js');
|
||||
* const youtube = await new Innertube();
|
||||
* ```
|
||||
* @param {string} [cookie]
|
||||
* @returns {Innertube}
|
||||
* @constructor
|
||||
*/
|
||||
constructor(cookie) {
|
||||
this.cookie = cookie || '';
|
||||
this.retry_count = 0;
|
||||
this.#retry_count = 0;
|
||||
return this.#init();
|
||||
}
|
||||
|
||||
@@ -28,44 +40,216 @@ class Innertube {
|
||||
try {
|
||||
const data = JSON.parse(`{${Utils.getStringBetweenStrings(response.data, 'ytcfg.set({', '});')}}`);
|
||||
if (data.INNERTUBE_CONTEXT) {
|
||||
this.context = data.INNERTUBE_CONTEXT;
|
||||
this.key = data.INNERTUBE_API_KEY;
|
||||
this.id_token = data.ID_TOKEN;
|
||||
this.session_token = data.XSRF_TOKEN;
|
||||
this.context = data.INNERTUBE_CONTEXT;
|
||||
|
||||
this.player_url = data.PLAYER_JS_URL;
|
||||
this.logged_in = data.LOGGED_IN;
|
||||
this.sts = data.STS;
|
||||
|
||||
this.context.client.hl = 'en';
|
||||
this.context.client.gl = 'US';
|
||||
|
||||
/**
|
||||
* @event auth - Fired when signing in to an account.
|
||||
* @event update-credentials - Fired when the access token is no longer valid.
|
||||
* @type {EventEmitter}
|
||||
*/
|
||||
this.ev = new EventEmitter();
|
||||
|
||||
this.player = new Player(this);
|
||||
await this.player.init();
|
||||
this.#player = new Player(this);
|
||||
await this.#player.init();
|
||||
|
||||
if (this.logged_in && this.cookie.length > 1) {
|
||||
this.auth_apisid = Utils.getStringBetweenStrings(this.cookie, 'PAPISID=', ';');
|
||||
this.auth_apisid = Utils.generateSidAuth(this.auth_apisid);
|
||||
}
|
||||
|
||||
this.#initMethods();
|
||||
} else {
|
||||
throw new Error('Could not retrieve Innertube session due to unknown reasons');
|
||||
}
|
||||
} catch (err) {
|
||||
this.retry_count += 1;
|
||||
if (this.retry_count >= 10) throw new Error(`Could not retrieve Innertube session: ${err.message}`);
|
||||
this.#retry_count += 1;
|
||||
if (this.#retry_count >= 10) throw new Error(`Could not retrieve Innertube session: ${err.message}`);
|
||||
return this.#init();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
#initMethods() {
|
||||
this.account = {
|
||||
info: () => this.getAccountInfo(),
|
||||
settings: {
|
||||
notifications: {
|
||||
/**
|
||||
* Notify about activity from the channels you're subscribed to.
|
||||
*
|
||||
* @param {boolean} new_value
|
||||
* @returns {Promise<{success: boolean; status_code: string; }>}
|
||||
*/
|
||||
setSubscriptions: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SUBSCRIPTIONS, 'account_notifications', new_value),
|
||||
|
||||
/**
|
||||
* Recommended content notifications.
|
||||
*
|
||||
* @param {boolean} new_value
|
||||
* @returns {Promise<{success: boolean; status_code: string; }>}
|
||||
*/
|
||||
setRecommendedVideos: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.RECOMMENDED_VIDEOS, 'account_notifications', new_value),
|
||||
|
||||
/**
|
||||
* Notify about activity on your channel.
|
||||
*
|
||||
* @param {boolean} new_value
|
||||
* @returns {Promise<{success: boolean; status_code: string; }>}
|
||||
*/
|
||||
setChannelActivity: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.CHANNEL_ACTIVITY, 'account_notifications', new_value),
|
||||
|
||||
/**
|
||||
* Notify about replies to your comments.
|
||||
*
|
||||
* @param {boolean} new_value
|
||||
* @returns {Promise<{success: boolean; status_code: string; }>}
|
||||
*/
|
||||
setCommentReplies: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.COMMENT_REPLIES, 'account_notifications', new_value),
|
||||
|
||||
/**
|
||||
* Notify when others mention your channel.
|
||||
*
|
||||
* @param {boolean} new_value
|
||||
* @returns {Promise<{success: boolean; status_code: string; }>}
|
||||
*/
|
||||
setMentions: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.USER_MENTION, 'account_notifications', new_value),
|
||||
|
||||
/**
|
||||
* Notify when others share your content on their channels.
|
||||
*
|
||||
* @param {boolean} new_value
|
||||
* @returns {Promise<{success: boolean; status_code: string; }>}
|
||||
*/
|
||||
setSharedContent: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SHARED_CONTENT, 'account_notifications', new_value)
|
||||
},
|
||||
privacy: {
|
||||
/**
|
||||
* If set to true, your subscriptions won't be visible to others.
|
||||
*
|
||||
* @param {boolean} new_value
|
||||
* @returns {Promise<{success: boolean; status_code: string; }>}
|
||||
*/
|
||||
setSubscriptionsPrivate: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SUBSCRIPTIONS_PRIVACY, 'account_privacy', new_value),
|
||||
|
||||
/**
|
||||
* If set to true, saved playlists won't appear on your channel.
|
||||
*
|
||||
* @param {boolean} new_value
|
||||
* @returns {Promise<{success: boolean; status_code: string; }>}
|
||||
*/
|
||||
setSavedPlaylistsPrivate: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.PLAYLISTS_PRIVACY, 'account_privacy', new_value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.interact = {
|
||||
/**
|
||||
* Likes a given video.
|
||||
*
|
||||
* @param {string} video_id
|
||||
* @returns {Promise<{success: boolean; status_code: string; }>}
|
||||
*/
|
||||
like: (video_id) => Actions.engage(this, 'like/like', { video_id }),
|
||||
|
||||
/**
|
||||
* Diskes a given video.
|
||||
*
|
||||
* @param {string} video_id
|
||||
* @returns {Promise<{success: boolean; status_code: string; }>}
|
||||
*/
|
||||
dislike: (video_id) => Actions.engage(this, 'like/dislike', { video_id }),
|
||||
|
||||
/**
|
||||
* Removes a like/dislike.
|
||||
*
|
||||
* @param {string} video_id
|
||||
* @returns {Promise<{success: boolean; status_code: string; }>}
|
||||
*/
|
||||
removeLike: (video_id) => Actions.engage(this, 'like/removelike', { video_id }),
|
||||
|
||||
/**
|
||||
* Posts a comment on a given video.
|
||||
*
|
||||
* @param {string} video_id
|
||||
* @param {string} text
|
||||
* @returns {Promise<{success: boolean; status_code: string; }>}
|
||||
*/
|
||||
comment: (video_id, text) => Actions.engage(this, 'comment/create_comment', { video_id, text }),
|
||||
|
||||
/**
|
||||
* Subscribes to a given channel.
|
||||
*
|
||||
* @param {string} channel_id
|
||||
* @returns {Promise<{success: boolean; status_code: string; }>}
|
||||
*/
|
||||
subscribe: (channel_id) => Actions.engage(this, 'subscription/subscribe', { channel_id }),
|
||||
|
||||
/**
|
||||
* Unsubscribes from a given channel.
|
||||
*
|
||||
* @param {string} channel_id
|
||||
* @returns {Promise<{success: boolean; status_code: string; }>}
|
||||
*/
|
||||
unsubscribe: (channel_id) => Actions.engage(this, 'subscription/unsubscribe', { channel_id }),
|
||||
|
||||
/**
|
||||
* Changes notification preferences for a given channel.
|
||||
* Only works with channels you are subscribed to.
|
||||
*
|
||||
* @param {string} channel_id
|
||||
* @param {string} type PERSONALIZED | ALL | NONE
|
||||
* @returns {Promise<{success: boolean; status_code: string; }>}
|
||||
*/
|
||||
changeNotificationPreferences: (channel_id, type) => Actions.notifications(this, 'modify_channel_preference', { channel_id, pref: type || 'NONE' }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to perform changes on an account's settings.
|
||||
*
|
||||
* @param {string} setting_id
|
||||
* @param {string} type
|
||||
* @param {string} new_value
|
||||
* @returns {Promise<{success: boolean; status_code: string; }>}
|
||||
*/
|
||||
async #setSetting(setting_id, type, new_value) {
|
||||
const response = await Actions.browse(this, type);
|
||||
|
||||
const contents = response.data.contents.twoColumnBrowseResultsRenderer.tabs[0]
|
||||
.tabRenderer.content.sectionListRenderer.contents[1]
|
||||
.itemSectionRenderer.contents.find((content) => content.settingsOptionsRenderer.options)
|
||||
.settingsOptionsRenderer.options;
|
||||
|
||||
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 });
|
||||
|
||||
return {
|
||||
success: set_setting.success,
|
||||
status_code: response.status_code,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs-in to a google account.
|
||||
*
|
||||
* @param {object} auth_info { refresh_token: string, access_token: string, expires: string }
|
||||
* @returns {Promise<void>}
|
||||
* @param {object} auth_info
|
||||
* @param {string} auth_info.access_token - Token used to sign in.
|
||||
* @param {string} auth_info.refresh_token - Token used to get a new access token.
|
||||
* @param {Date} auth_info.expires - Access token's expiration date, which is usually 24hrs-ish
|
||||
* @returns {Promise.<void>}
|
||||
*/
|
||||
signIn(auth_info = {}) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
return new Promise(async (resolve) => {
|
||||
const oauth = new OAuth(auth_info);
|
||||
if (auth_info.access_token) {
|
||||
if (!oauth.isTokenValid()) {
|
||||
@@ -96,41 +280,106 @@ class Innertube {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns information about the account being used.
|
||||
* @returns {Promise<{ name: string; photo: Array<object>; country: string; language: string; }>}
|
||||
*/
|
||||
async getAccountInfo() {
|
||||
const response = await Actions.account(this, 'account/account_menu');
|
||||
if (!response.success) throw new Error('Could not get account info');
|
||||
|
||||
const menu = response.data.actions[0].openPopupAction.popup.multiPageMenuRenderer;
|
||||
|
||||
return {
|
||||
name: menu.header.activeAccountHeaderRenderer.accountName.simpleText,
|
||||
photo: menu.header.activeAccountHeaderRenderer.accountPhoto.thumbnails,
|
||||
country: menu.sections[1].multiPageMenuSectionRenderer.items[2].compactLinkRenderer.subtitle.simpleText,
|
||||
language: menu.sections[1].multiPageMenuSectionRenderer.items[1].compactLinkRenderer.subtitle.simpleText
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches on YouTube.
|
||||
*
|
||||
* @param {string} query Search query.
|
||||
* @param {object} options { client: YOUTUBE | YTMUSIC, period: any | hour | day | week | month | year , order: relevance | rating | age | views, duration: any | short | long }
|
||||
* @returns {Promise<object>} Search results.
|
||||
* @param {string} query - Search query.
|
||||
* @param {object} options - Search options.
|
||||
* @param {string} options.client - Client used to perform the search, can be: `YTMUSIC` or `YOUTUBE`.
|
||||
* @param {string} options.period - Filter videos uploaded within a period, can be: any | hour | day | week | month | year
|
||||
* @param {string} options.order - Filter results by order, can be: relevance | rating | age | views
|
||||
* @param {string} options.duration - Filter video results by duration, can be: any | short | long
|
||||
* @returns {Promise.<{ query: string; corrected_query: string; estimated_results: number; videos: [] } |
|
||||
* { songs: []; videos: []; albums: []; playlists: [] }>}
|
||||
*/
|
||||
async search(query, options = { client: 'YOUTUBE', period: 'any', order: 'relevance', duration: 'any' }) {
|
||||
const response = await Actions.search(this, options.client, { query, options });
|
||||
if (!response.success) throw new Error(`Could not search on YouTube: ${response.message}`);
|
||||
|
||||
const refined_data = new Parser(this, response.data, {
|
||||
const data = new Parser(this, response.data, {
|
||||
client: options.client,
|
||||
data_type: 'SEARCH',
|
||||
query
|
||||
}).parse();
|
||||
|
||||
return refined_data;
|
||||
return 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`.
|
||||
* @returns {Promise.<[{ text: string; bold_text: string }]>}
|
||||
*/
|
||||
async getSearchSuggestions(input, { client = 'YOUTUBE' }) {
|
||||
if (client == 'YOUTUBE') {
|
||||
const response = await Actions.getYTSearchSuggestions(this, input);
|
||||
if (!response.success) throw new Error('Could not get search suggestions');
|
||||
|
||||
return response.data[1].map((item) => {
|
||||
return {
|
||||
text: item.trim(),
|
||||
bold_text: response.data[0].trim()
|
||||
};
|
||||
});
|
||||
} else if (client == 'YTMUSIC') {
|
||||
const response = await Actions.music(this, 'get_search_suggestions', { input });
|
||||
if (!response.success) throw new Error('Could not get search suggestions');
|
||||
if (!response.data.contents) return [];
|
||||
|
||||
const contents = response.data.contents[0].searchSuggestionsSectionRenderer.contents;
|
||||
return contents.map((item) => {
|
||||
let suggestion;
|
||||
|
||||
if (item.historySuggestionRenderer) {
|
||||
suggestion = item.historySuggestionRenderer.suggestion;
|
||||
} else {
|
||||
suggestion = item.searchSuggestionRenderer.suggestion;
|
||||
}
|
||||
|
||||
return {
|
||||
text: suggestion.runs.map((run) => run.text).join('').trim(),
|
||||
bold_text: suggestion.runs[0].text.trim()
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets details for a video.
|
||||
*
|
||||
* @param {string} id The id of the video.
|
||||
* @param {string} video_id - The id of the video.
|
||||
* @return {Promise.<{ title: string; description: string; thumbnail: []; metadata: {} }>}
|
||||
*/
|
||||
async getDetails(id) {
|
||||
if (!id) throw new Error('You must provide a video id');
|
||||
async getDetails(video_id) {
|
||||
if (!video_id) throw new Error('You must provide a video id');
|
||||
|
||||
const data = await Actions.getVideoInfo(this, { id, is_desktop: false });
|
||||
const data = await Actions.getVideoInfo(this, { id: video_id, is_desktop: false });
|
||||
const refined_data = new Parser(this, data, { client: 'YOUTUBE', data_type: 'VIDEO_INFO', desktop_v: false }).parse();
|
||||
|
||||
if (refined_data.metadata.is_live_content) {
|
||||
const data_continuation = await Actions.getContinuation(this, { video_id: id });
|
||||
const data_continuation = await Actions.getContinuation(this, { video_id });
|
||||
if (data_continuation.data.contents.twoColumnWatchNextResults.conversationBar) {
|
||||
refined_data.getLivechat = () => new Livechat(this, data_continuation.data.contents.twoColumnWatchNextResults.conversationBar.liveChatRenderer.continuations[0].reloadContinuationData.continuation, refined_data.metadata.channel_id, id);
|
||||
refined_data.getLivechat = () => new Livechat(this, data_continuation.data.contents.twoColumnWatchNextResults.conversationBar.liveChatRenderer.continuations[0].reloadContinuationData.continuation, refined_data.metadata.channel_id, video_id);
|
||||
} else {
|
||||
refined_data.getLivechat = () => { };
|
||||
}
|
||||
@@ -138,26 +387,26 @@ class Innertube {
|
||||
refined_data.getLivechat = () => { };
|
||||
}
|
||||
|
||||
refined_data.like = () => Actions.engage(this, 'like/like', { video_id: id });
|
||||
refined_data.dislike = () => Actions.engage(this, 'like/dislike', { video_id: id });
|
||||
refined_data.removeLike = () => Actions.engage(this, 'like/removelike', { video_id: id });
|
||||
refined_data.subscribe = () => Actions.engage(this, 'subscription/subscribe', { video_id: id, channel_id: refined_data.metadata.channel_id });
|
||||
refined_data.unsubscribe = () => Actions.engage(this, 'subscription/unsubscribe', { video_id: id, channel_id: refined_data.metadata.channel_id });
|
||||
refined_data.comment = text => Actions.engage(this, 'comment/create_comment', { video_id: id, text });
|
||||
refined_data.getComments = () => this.getComments(id);
|
||||
refined_data.setNotificationPref = pref => Actions.notifications(this, 'modify_channel_preference', { channel_id: refined_data.metadata.channel_id, pref: pref || 'NONE' });
|
||||
refined_data.like = () => Actions.engage(this, 'like/like', { video_id });
|
||||
refined_data.dislike = () => Actions.engage(this, 'like/dislike', { video_id });
|
||||
refined_data.removeLike = () => Actions.engage(this, 'like/removelike', { video_id });
|
||||
refined_data.subscribe = () => Actions.engage(this, 'subscription/subscribe', { channel_id: refined_data.metadata.channel_id });
|
||||
refined_data.unsubscribe = () => Actions.engage(this, 'subscription/unsubscribe', { channel_id: refined_data.metadata.channel_id });
|
||||
refined_data.comment = (text) => Actions.engage(this, 'comment/create_comment', { video_id, text });
|
||||
refined_data.getComments = () => this.getComments(video_id);
|
||||
refined_data.changeNotificationPreferences = (type) => Actions.notifications(this, 'modify_channel_preference', { channel_id: refined_data.metadata.channel_id, pref: type || 'NONE' });
|
||||
|
||||
return refined_data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the lyrics for a given song
|
||||
* Retrieves the lyrics for a given song if available.
|
||||
*
|
||||
* @param {string} id
|
||||
* @returns {string} Song lyrics
|
||||
* @param {string} video_id
|
||||
* @returns {Promise.<string>} Song lyrics
|
||||
*/
|
||||
async getLyrics(id) {
|
||||
const data_continuation = await Actions.getContinuation(this, { video_id: id, ytmusic: true });
|
||||
async getLyrics(video_id) {
|
||||
const data_continuation = await Actions.getContinuation(this, { video_id: video_id, ytmusic: true });
|
||||
|
||||
const lyrics_tab = data_continuation.data.contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer
|
||||
.watchNextTabbedResultsRenderer.tabs.find((obj) => obj.tabRenderer.title == 'Lyrics');
|
||||
@@ -166,14 +415,31 @@ class Innertube {
|
||||
if (!response.data.contents.sectionListRenderer) throw new Error(response.data.contents.messageRenderer.text.runs[0].text);
|
||||
|
||||
const lyrics = response.data.contents.sectionListRenderer.contents[0].musicDescriptionShelfRenderer.description.runs[0].text;
|
||||
return lyrics
|
||||
return lyrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a given playlist.
|
||||
*
|
||||
* @param {string} playlist_id - The id of the playlist.
|
||||
* @param {object} options - { client: YOUTUBE | YTMUSIC }
|
||||
* @param {string} options.client - Client used to parse the playlist, can be: `YTMUSIC` | `YOUTUBE`
|
||||
* @returns {Promise.<
|
||||
* { title: string; description: string; total_items: string; last_updated: string; views: string; items: [] } |
|
||||
* { title: string; description: string; total_items: number; duration: string; year: string; items: [] }>}
|
||||
*/
|
||||
async getPlaylist(playlist_id, options = { client: 'YOUTUBE' }) {
|
||||
const response = await Actions.browse(this, options.client == 'YTMUSIC' ? 'music_playlist' : 'playlist', { ytmusic: options.client == 'YTMUSIC', browse_id: `VL${playlist_id}` });
|
||||
const data = new Parser(this, response.data, { client: options.client, data_type: 'PLAYLIST' }).parse();
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the comments section of a video.
|
||||
*
|
||||
* @param {string} video_id The id of the video.
|
||||
* @param {string} token Continuation token (optional).
|
||||
* @param {string} video_id - The id of the video.
|
||||
* @param {string} [token] - Continuation token (optional).
|
||||
* @return {Promise.<[{ comments: []; comment_count: string }]>
|
||||
*/
|
||||
async getComments(video_id, token) {
|
||||
let comment_section_token;
|
||||
@@ -224,9 +490,45 @@ class Innertube {
|
||||
return comments_section;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns your watch history.
|
||||
* @returns {Promise.<[{ id: string; title: string; channel: string; metadata: {} }]>}
|
||||
*/
|
||||
async getHistory() {
|
||||
const response = await Actions.browse(this, 'history');
|
||||
const contents = response.data.contents.twoColumnBrowseResultsRenderer.tabs[0]
|
||||
.tabRenderer.content.sectionListRenderer.contents;
|
||||
|
||||
const history = [];
|
||||
|
||||
contents.forEach((section) => {
|
||||
if (!section.itemSectionRenderer) return;
|
||||
const section_items = section.itemSectionRenderer.contents;
|
||||
section_items.forEach((item) => {
|
||||
const content = {
|
||||
id: item.videoRenderer.videoId,
|
||||
title: item.videoRenderer.title.runs.map((run) => run.text).join(' '),
|
||||
channel: item.videoRenderer.shortBylineText && item.videoRenderer.shortBylineText.runs[0].text || 'N/A',
|
||||
metadata: {
|
||||
view_count: item.videoRenderer.viewCountText && item.videoRenderer.viewCountText.simpleText || 'N/A',
|
||||
thumbnail: item.videoRenderer.thumbnail && item.videoRenderer.thumbnail.thumbnails.slice(-1)[0] || [],
|
||||
moving_thumbnail: item.videoRenderer.richThumbnail && item.videoRenderer.richThumbnail.movingThumbnailRenderer.movingThumbnailDetails.thumbnails[0] || [],
|
||||
published: item.videoRenderer.publishedTimeText && item.videoRenderer.publishedTimeText.simpleText || 'N/A',
|
||||
duration: item.videoRenderer.lengthText && item.videoRenderer.lengthText.simpleText || 'N/A',
|
||||
badges: item.videoRenderer.badges && item.videoRenderer.badges.map((badge) => badge.metadataBadgeRenderer.label) || 'N/A',
|
||||
owner_badges: item.videoRenderer.ownerBadges && item.videoRenderer.ownerBadges.map((badge) => badge.metadataBadgeRenderer.tooltip) || 'N/A'
|
||||
}
|
||||
};
|
||||
history.push(content);
|
||||
});
|
||||
});
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns YouTube's home feed.
|
||||
* @returns {Promise<object>} home feed.
|
||||
* @returns {Promise.<[{ id: string; title: string; channel: string; metadata: {} }]>}
|
||||
*/
|
||||
async getHomeFeed() {
|
||||
const response = await Actions.browse(this, 'home_feed');
|
||||
@@ -256,7 +558,7 @@ class Innertube {
|
||||
|
||||
/**
|
||||
* Returns your subscription feed.
|
||||
* @returns {Promise<object>} subs feed.
|
||||
* @returns {Promise.<{ today: []; yesterday: []; this_week: [] }>}
|
||||
*/
|
||||
async getSubscriptionsFeed() {
|
||||
const response = await Actions.browse(this, 'subscriptions_feed');
|
||||
@@ -296,8 +598,8 @@ class Innertube {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns your notifications.
|
||||
* @returns {Promise<object>} notifications.
|
||||
* Retrieves your notifications.
|
||||
* @returns {Promise.<[{ title: string; sent_time: string; channel_name: string; channel_thumbnail: {}; video_thumbnail: {}; video_url: string; read: boolean; notification_id: string }]>}
|
||||
*/
|
||||
async getNotifications() {
|
||||
const response = await Actions.notifications(this, 'get_notification_menu');
|
||||
@@ -322,8 +624,8 @@ class Innertube {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the amount of notifications you haven't seen.
|
||||
* @returns {Promise<number>} unseen notifications count.
|
||||
* Returns unseen notifications count.
|
||||
* @returns {Promise.<number>} unseen notifications count.
|
||||
*/
|
||||
async getUnseenNotificationsCount() {
|
||||
const response = await Actions.notifications(this, 'get_unseen_count');
|
||||
@@ -332,10 +634,103 @@ class Innertube {
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a video from YouTube.
|
||||
* Internal method to process and filter formats.
|
||||
*
|
||||
* @param {string} id The id of the video.
|
||||
* @param {object} options Download options: { quality?: string, type?: string, format?: string }
|
||||
* @param {object} options
|
||||
* @param {object} video_data
|
||||
* @returns {object.<{ selected_format: {}; formats: [] }>}
|
||||
*/
|
||||
#chooseFormat(options, video_data) {
|
||||
let formats = [];
|
||||
|
||||
formats = formats
|
||||
.concat(video_data.streamingData.formats || [])
|
||||
.concat(video_data.streamingData.adaptiveFormats || []);
|
||||
|
||||
formats.forEach((format) => {
|
||||
format.url = format.url || format.signatureCipher || format.cipher;
|
||||
|
||||
if (format.signatureCipher || format.cipher) {
|
||||
format.url = new SigDecipher(format.url, this.#player).decipher();
|
||||
}
|
||||
|
||||
const url_components = new URL(format.url);
|
||||
url_components.searchParams.set('cver', this.context.client.clientVersion);
|
||||
url_components.searchParams.set('ratebypass', 'yes');
|
||||
url_components.searchParams.set('n', new NToken(this.#player.ntoken_sc).transform(url_components.searchParams.get('n')));
|
||||
|
||||
format.url = url_components.toString();
|
||||
format.has_audio = !!format.audioBitrate || !!format.audioQuality;
|
||||
format.has_video = !!format.qualityLabel;
|
||||
|
||||
delete format.cipher;
|
||||
delete format.signatureCipher;
|
||||
});
|
||||
|
||||
formats.hls_manifest_url = video_data.streamingData.hlsManifestUrl || undefined;
|
||||
formats.dash_manifest_url = video_data.streamingData.dashManifestUrl || undefined;
|
||||
|
||||
let format;
|
||||
let bitrates;
|
||||
let filtered_formats;
|
||||
|
||||
filtered_formats = ({
|
||||
'video': formats.filter((format) => format.has_video && !format.has_audio),
|
||||
'audio': formats.filter((format) => format.has_audio && !format.has_video),
|
||||
'videoandaudio': formats.filter((format) => format.has_video && format.has_audio)
|
||||
})[options.type] || formats.filter((format) => format.has_video && format.has_audio);
|
||||
|
||||
if (options.type != 'videoandaudio') {
|
||||
let streams;
|
||||
|
||||
options.type != 'audio' &&
|
||||
(streams = filtered_formats.filter((format) => format.mimeType.includes(options.format || 'mp4') && format.qualityLabel == options.quality)) ||
|
||||
(streams = filtered_formats.filter((format) => format.mimeType.includes(options.format || 'mp4')));
|
||||
|
||||
streams == undefined || streams.length == 0 &&
|
||||
(streams = filtered_formats.filter((format) => format.quality == 'medium'));
|
||||
|
||||
bitrates = streams.map((format) => format.bitrate);
|
||||
format = streams.filter((format) => format.bitrate === Math.max(...bitrates))[0];
|
||||
} else {
|
||||
format = filtered_formats[0];
|
||||
}
|
||||
|
||||
return { selected_format: format, formats };
|
||||
}
|
||||
|
||||
/**
|
||||
* An alternative to {@link download}.
|
||||
* Returns deciphered streaming data.
|
||||
*
|
||||
* @param {string} id - The id of the video.
|
||||
* @param {object} options - Download options.
|
||||
* @param {string} options.quality - Video quality; 360p, 720p, 1080p, etc....
|
||||
* @param {string} options.type - Download type, can be: video, audio or videoandaudio
|
||||
* @param {string} options.format - File format
|
||||
* @returns {Promise.<{ selected_format: {}; formats: [] }>}
|
||||
*/
|
||||
async getStreamingData(id, options = {}) {
|
||||
options.quality = options.quality || '360p';
|
||||
options.type = options.type || 'videoandaudio';
|
||||
options.format = options.format || 'mp4';
|
||||
|
||||
const data = await Actions.getVideoInfo(this, { id, desktop: true });
|
||||
const streaming_data = this.#chooseFormat(options, data);
|
||||
if (!streaming_data.selected_format) throw new Error('Could not find any suitable format.');
|
||||
|
||||
return streaming_data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a given video. If you only need the direct download link take a look at {@link getStreamingData}.
|
||||
*
|
||||
* @param {string} id - The id of the video.
|
||||
* @param {object} options - Download options.
|
||||
* @param {string} options.quality - Video quality; 360p, 720p, 1080p, etc....
|
||||
* @param {string} options.type - Download type, can be: video, audio or videoandaudio
|
||||
* @param {string} options.format - File format
|
||||
* @return {ReadableStream}
|
||||
*/
|
||||
download(id, options = {}) {
|
||||
if (!id) throw new Error('Missing video id');
|
||||
@@ -349,71 +744,18 @@ class Innertube {
|
||||
|
||||
const stream = new Stream.PassThrough();
|
||||
Actions.getVideoInfo(this, { id, desktop: true }).then(async (video_data) => {
|
||||
let formats = [];
|
||||
|
||||
if (video_data.playabilityStatus.status === 'LOGIN_REQUIRED')
|
||||
return stream.emit('error', { message: 'You must login to download age-restricted videos.', error_type: 'LOGIN_REQUIRED', playability_status: video_data.playabilityStatus.status });
|
||||
if (!video_data.streamingData)
|
||||
return stream.emit('error', { message: 'Streaming data not available.', error_type: 'NO_STREAMING_DATA', playability_status: video_data.playabilityStatus.status });
|
||||
|
||||
formats = formats
|
||||
.concat(video_data.streamingData.formats || [])
|
||||
.concat(video_data.streamingData.adaptiveFormats || []);
|
||||
|
||||
formats.forEach((format) => {
|
||||
format.url = format.url || format.signatureCipher || format.cipher;
|
||||
|
||||
if (format.signatureCipher || format.cipher) {
|
||||
format.url = new SigDecipher(format.url, this.context.client.clientVersion, this.player).decipher();
|
||||
} else {
|
||||
const url_components = new URL(format.url);
|
||||
url_components.searchParams.set('cver', this.context.client.clientVersion);
|
||||
url_components.searchParams.set('ratebypass', 'yes');
|
||||
url_components.searchParams.set('n', new NToken(this.player.ntoken_sc).transform(url_components.searchParams.get('n')));
|
||||
format.url = url_components.toString();
|
||||
}
|
||||
|
||||
format.has_audio = !!format.audioBitrate || !!format.audioQuality;
|
||||
format.has_video = !!format.qualityLabel;
|
||||
|
||||
delete format.cipher;
|
||||
delete format.signatureCipher;
|
||||
});
|
||||
|
||||
formats.hls_manifest_url = video_data.streamingData.hlsManifestUrl || undefined;
|
||||
formats.dash_manifest_url = video_data.streamingData.dashManifestUrl || undefined;
|
||||
|
||||
let format;
|
||||
let bitrates;
|
||||
let filtered_formats;
|
||||
|
||||
filtered_formats = ({
|
||||
'video': formats.filter((format) => format.has_video && !format.has_audio),
|
||||
'audio': formats.filter((format) => format.has_audio && !format.has_video),
|
||||
'videoandaudio': formats.filter((format) => format.has_video && format.has_audio)
|
||||
})[options.type] || formats.filter((format) => format.has_video && format.has_audio);
|
||||
|
||||
if (options.type != 'videoandaudio') {
|
||||
let streams;
|
||||
|
||||
options.type != 'audio' &&
|
||||
(streams = filtered_formats.filter((format) => format.mimeType.includes(options.format || 'mp4') && format.qualityLabel == options.quality)) ||
|
||||
(streams = filtered_formats.filter((format) => format.mimeType.includes(options.format || 'mp4')));
|
||||
|
||||
streams == undefined || streams.length == 0 &&
|
||||
(streams = filtered_formats.filter((format) => format.quality == 'medium'));
|
||||
|
||||
bitrates = streams.map((format) => format.bitrate);
|
||||
format = streams.filter((format) => format.bitrate === Math.max(...bitrates))[0];
|
||||
} else {
|
||||
format = filtered_formats[0];
|
||||
}
|
||||
const { selected_format: format, formats } = this.#chooseFormat(options, video_data);
|
||||
|
||||
if (!format)
|
||||
return stream.emit('error', { message: 'Could not find any suitable format.', type: 'FORMAT_UNAVAILABLE' });
|
||||
|
||||
const refined_data = new Parser(this, video_data, { client: 'YOUTUBE', data_type: 'VIDEO_INFO', desktop_v: true }).parse();
|
||||
stream.emit('info', { video_details: refined_data, selected_format: format, formats });
|
||||
const video_details = new Parser(this, video_data, { client: 'YOUTUBE', data_type: 'VIDEO_INFO', desktop_v: true }).parse();
|
||||
stream.emit('info', { video_details, selected_format: format, formats });
|
||||
|
||||
if (options.type == 'videoandaudio' && !options.range) {
|
||||
const response = await Axios.get(format.url, {
|
||||
|
||||
142
lib/Parser.js
142
lib/Parser.js
@@ -1,7 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
const Utils = require('./Utils');
|
||||
const Actions = require('./Actions');
|
||||
const Constants = require('./Constants');
|
||||
|
||||
/**
|
||||
@@ -18,10 +17,11 @@ class Parser {
|
||||
parse() {
|
||||
return this.args.client === 'YOUTUBE' ? ({
|
||||
SEARCH: () => this.#parseVideoSearch(),
|
||||
PLAYLIST: () => this.#parsePlaylist(),
|
||||
VIDEO_INFO: () => this.#parseVideoInfo()
|
||||
})[this.args.data_type]() : ({
|
||||
SEARCH: () => this.#parseMusicSearch(),
|
||||
SONG_INFO: () => { }
|
||||
PLAYLIST: () => this.#parseMusicPlaylist()
|
||||
})[this.args.data_type]();
|
||||
}
|
||||
|
||||
@@ -37,10 +37,9 @@ class Parser {
|
||||
// .primaryContents.sectionListRenderer.contents[1].continuationItemRenderer
|
||||
// .continuationEndpoint.continuationCommand.token;
|
||||
|
||||
response.search_metadata = {};
|
||||
response.search_metadata.query = contents[0].showingResultsForRenderer && contents[0].showingResultsForRenderer.originalQuery.simpleText || this.args.query;
|
||||
response.search_metadata.corrected_query = contents[0].showingResultsForRenderer && contents[0].showingResultsForRenderer.correctedQueryEndpoint.searchEndpoint.query || this.args.query;
|
||||
response.search_metadata.estimated_results = parseInt(this.data.estimatedResults);
|
||||
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;
|
||||
@@ -74,37 +73,77 @@ class Parser {
|
||||
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;
|
||||
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)
|
||||
};
|
||||
});
|
||||
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;
|
||||
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)
|
||||
};
|
||||
});
|
||||
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) => {
|
||||
@@ -117,7 +156,56 @@ class Parser {
|
||||
};
|
||||
});
|
||||
|
||||
return { songs, videos, albums };
|
||||
const playlists_ms = contents.find((content) => content.musicShelfRenderer.title.runs[0].text == 'Community playlists');
|
||||
const playlists = playlists_ms.musicShelfRenderer.contents.map((item) => {
|
||||
const list_item = item.musicResponsiveListItemRenderer;
|
||||
const watch_playlist_endpoint = list_item.overlay.musicItemThumbnailOverlayRenderer.content.musicPlayButtonRenderer
|
||||
.playNavigationEndpoint.watchPlaylistEndpoint;
|
||||
|
||||
return {
|
||||
id: watch_playlist_endpoint.playlistId,
|
||||
title: list_item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
|
||||
author: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[2].text,
|
||||
channel_id: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[2].navigationEndpoint.browseEndpoint.browseId,
|
||||
total_items: parseInt(list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[4].text.match(/\d+/g)),
|
||||
};
|
||||
});
|
||||
|
||||
return { songs, videos, albums, playlists };
|
||||
}
|
||||
|
||||
#parseMusicPlaylist() {
|
||||
const details = this.data.header.musicDetailHeaderRenderer;
|
||||
|
||||
const metadata = {
|
||||
title: details.title.runs[0].text,
|
||||
description: details.description && details.description.runs.map((run) => run.text).join('') || 'N/A',
|
||||
total_items: parseInt(details.secondSubtitle.runs[0].text.match(/\d+/g)),
|
||||
duration: details.secondSubtitle.runs[2].text,
|
||||
year: details.subtitle.runs[4].text
|
||||
};
|
||||
|
||||
const contents = this.data.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents;
|
||||
const playlist_content = contents[0].musicPlaylistShelfRenderer.contents;
|
||||
|
||||
const items = playlist_content.map((item) => {
|
||||
const item_renderer = item.musicResponsiveListItemRenderer;
|
||||
const fixed_columns = item_renderer.fixedColumns;
|
||||
const flex_columns = item_renderer.flexColumns;
|
||||
|
||||
return {
|
||||
id: item_renderer.playlistItemData && item_renderer.playlistItemData.videoId,
|
||||
title: flex_columns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
|
||||
author: flex_columns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
|
||||
duration: fixed_columns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text,
|
||||
thumbnail: item_renderer.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails
|
||||
}
|
||||
}).filter((item) => item.id);
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
#parseVideoInfo() {
|
||||
|
||||
Reference in New Issue
Block a user