diff --git a/.eslintrc.yml b/.eslintrc.yml index bb8117ed..e0f02d71 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -20,7 +20,7 @@ rules: no-template-curly-in-string: error no-unreachable-loop: error - no-unused-private-class-members: error + no-unused-private-class-members: 'off' no-prototype-builtins: 'off' no-async-promise-executor: 'off' no-case-declarations: 'off' diff --git a/lib/Innertube.js b/lib/Innertube.js index ad54e92a..5cfd6c5c 100644 --- a/lib/Innertube.js +++ b/lib/Innertube.js @@ -13,6 +13,8 @@ const SessionBuilder = require('./core/SessionBuilder'); const AccountManager = require('./core/AccountManager'); const PlaylistManager = require('./core/PlaylistManager'); const InteractionManager = require('./core/InteractionManager'); +const VideoInfo = require('./core/VideoInfo'); +const Search = require('./core/Search'); const Utils = require('./utils/Utils'); const Request = require('./utils/Request'); @@ -27,9 +29,6 @@ const Signature = require('./deciphers/Signature'); * @namespace */ class Innertube { - /** - * @type {AxiosInstance} - */ #axios; #player; @@ -59,7 +58,6 @@ class Innertube { async #init() { const session = await new SessionBuilder(this.config).build(); - this.#axios = session.axios; this.key = session.key; this.version = session.api_version; this.context = session.context; @@ -68,6 +66,7 @@ class Innertube { this.player_url = session.player.url; this.sts = session.player.sts; + this.#axios = session.axios; this.#player = session.player; /** @@ -76,7 +75,7 @@ class Innertube { * @type {EventEmitter} */ this.ev = new EventEmitter(); - this.oauth = new OAuth(this.ev, this.#axios); + this.oauth = new OAuth(this.ev, session.axios); if (this.config.cookie) { this.auth_apisid = Utils.getStringBetweenStrings(this.config.cookie, 'PAPISID=', ';'); @@ -89,7 +88,7 @@ class Innertube { this.account = new AccountManager(this.actions); this.playlist = new PlaylistManager(this.actions); this.interact = new InteractionManager(this.actions); - + return this; } @@ -190,9 +189,50 @@ class Innertube { return suggestions; } + + /** + * Retrives video info. + * @returns {Promise.} + */ + async getInfo(video_id) { + Utils.throwIfMissing({ video_id }); + + const initial_info = this.actions.getVideoInfo(video_id); + const continuation = this.actions.next({ video_id }); + + const response = await Promise.all([ initial_info, continuation ]); + return new VideoInfo(response, this.actions, this.#player); + } + + /** + * Searches a given query. + * + * WIP — this will replace {@link search} soon. + * + * @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 {object} [options.filters] - search filters. + * @param {string} [options.filters.upload_date] - filter videos by upload date, can be: any | last_hour | today | this_week | this_month | this_year + * @param {string} [options.filters.type] - filter results by type, can be: any | video | channel | playlist | movie + * @param {string} [options.filters.duration] - filter videos by duration, can be: any | short | medium | long + * @param {string} [options.filters.sort_by] - filter video results by order, can be: relevance | rating | upload_date | view_count + * + * @returns {Promise.} + */ + async _search(query, options = { client: 'YOUTUBE' }) { + Utils.throwIfMissing({ query }); + + const response = await this.actions.search({ query, options, client: options.client }); + return new Search(response, this.actions); + } /** * Retrieves video info. + * + * @deprecated do not use this, it is slow and inefficient. + * Use {@link getInfo} instead. + * * @param {string} video_id - the video id. * @return {Promise.<{ title: string; description: string; thumbnail: []; metadata: object }>} */ @@ -655,9 +695,10 @@ class Innertube { return stream; } - + + /** @readonly */ get axios() { - return this.#axios; + return this.#axios; } } diff --git a/lib/core/AccountManager.js b/lib/core/AccountManager.js index 17aba626..36037dca 100644 --- a/lib/core/AccountManager.js +++ b/lib/core/AccountManager.js @@ -2,6 +2,8 @@ const Utils = require('../utils/Utils'); const Constants = require('../utils/Constants'); +const Library = require('./Library'); +const Analytics = require('./Analytics'); const Proto = require('../proto'); /** @namespace */ @@ -168,7 +170,7 @@ class AccountManager { /** * Retrieves time watched statistics. - * @returns {Promise.<[{ title: string; time: string; }]>} + * @returns {Promise.<{ title: string; time: string; }[]>} */ async getTimeWatched() { const response = await this.#actions.browse('SPtime_watched', { client: 'ANDROID' }); @@ -190,61 +192,24 @@ class AccountManager { /** * Retrieves basic channel analytics. - * - * @returns {Promise.<{ metrics: { title: string; subtitle: string; metric_value: string; - * comparison_indicator: object; series_configuration: object; }[]; top_content: { views: string; - * published: string; thumbnails: object[]; duration: string; is_short: boolean }[]; }>} + * @returns {Promise.} */ async getAnalytics() { const info = await this.getInfo(); const params = Proto.encodeChannelAnalyticsParams(info.channel_id); - const action = await this.#actions.browse('FEanalytics_screen', { params, client: 'ANDROID' }); - - const contents = Utils.findNode(action.data, 'contents', 'elementRenderer', 11, false); - - const analytics = { - metrics: {}, - top_content: {} - } - - contents.forEach((el) => { - const element = el.elementRenderer.newElement; - const model = element.type.componentType.model; - const key = Object.keys(model)[0]; + const response = await this.#actions.browse('FEanalytics_screen', { params, client: 'ANDROID' }); - switch (key) { - case 'analyticsRootModel': - const sections = model.analyticsRootModel.analyticsKeyMetricsData.dataModel.sections; - - analytics.metrics = sections.map((section) => ({ - title: section.title, - subtitle: section.subtitle, - metric_value: section.metricValue, - comparison_indicator: section.comparisonIndicator, - series_configuration: section.seriesConfiguration - })); - break; - case 'analyticsVodCarouselCardModel': - const video_carousel = model.analyticsVodCarouselCardModel.videoCarouselData; - - analytics.top_content = video_carousel?.videos.map((video) => ({ - title: video.videoTitle, - metadata: { - views: video.videoDescription.split('·')[0].trim(), - published: video.videoDescription.split('·')[1].trim(), - thumbnails: video.thumbnailDetails.thumbnails, - duration: video.formattedLength, - is_short: video.isShort - } - })) || []; - break; - default: - break; - } - }); - - return analytics; + return new Analytics(response.data); + } + + /** + * Returns the account's library. + * @returns {Promise.} + */ + async getLibrary() { + const response = await this.#actions.browse('FElibrary'); + return new Library(response.data, this.#actions); } } diff --git a/lib/core/Actions.js b/lib/core/Actions.js index 445939f6..f2247314 100644 --- a/lib/core/Actions.js +++ b/lib/core/Actions.js @@ -5,6 +5,11 @@ const Proto = require('../proto'); const Utils = require('../utils/Utils'); const Constants = require('../utils/Constants'); +/** +* API response. +* @typedef {Promise.<{ success: boolean; status_code: number; data: object; }>} Response +*/ + /** namespace **/ class Actions { #session; @@ -31,7 +36,7 @@ class Actions { * @param {boolean} [args.is_ctoken] * @param {string} [args.client] * - * @returns {Promise.<{ success: boolean; status_code: number; data: object }>} + * @returns {Response} */ async browse(id, args = {}) { if (this.#needsLogin(id) && !this.#session.logged_in) @@ -65,7 +70,7 @@ class Actions { * @param {string} [args.comment_id] * @param {string} [args.comment_action] * - * @returns {Promise.<{ success: boolean; status_code: number; data: object; }>} + * @returns {Response} */ async engage(action, args = {}) { if (!this.#session.logged_in && !args.hasOwnProperty('text')) @@ -119,7 +124,7 @@ class Actions { * @param {string} [args.new_value] * @param {string} [args.setting_item_id] * - * @returns {Promise.<{ success: boolean; status_code: number; data: object }>} + * @returns {Response} */ async account(action, args = {}) { if (!this.#session.logged_in) @@ -154,7 +159,7 @@ class Actions { * @param {string} [args.client] * @param {string} [args.ctoken] * - * @returns {Promise.<{ success: boolean; status_code: number; data: object; }>} + * @returns {Response} */ async search(args = {}) { const data = {}; @@ -183,7 +188,7 @@ class Actions { * @param {object} args * @param {string} args.query * - * @returns {Promise.<{ success: boolean; status_code: number; data: object; }>} + * @returns {Response} */ async searchSound(args = {}) { const data = { @@ -203,7 +208,7 @@ class Actions { * @param {string} [args.new_name] * @param {string} [args.new_description] * - * @returns {Promise.<{ success: boolean; status_code: number; data: object; }>} + * @returns {Response} */ async channel(action, args = {}) { if (!this.#session.logged_in) @@ -239,7 +244,7 @@ class Actions { * @param {string} [args.playlist_id] * @param {string} [args.action] * - * @returns {Promise.<{ success: boolean; status_code: number; data: object; }>} + * @returns {Response} */ async playlist(action, args = {}) { if (!this.#session.logged_in) @@ -286,7 +291,7 @@ class Actions { * @param {string} [args.channel_id] * @param {string} [args.ctoken] * - * @returns {Promise.<{ success: boolean; status_code: number; data: object; }>} + * @returns {Response} */ async notifications(action, args = {}) { if (!this.#session.logged_in) @@ -328,7 +333,7 @@ class Actions { * @param {string} [args.ctoken] * @param {string} [args.params] * - * @returns {Promise.<{ success: boolean; status_code: number; data: object; }>} + * @returns {Response} */ async livechat(action, args = {}) { const data = {}; @@ -369,7 +374,7 @@ class Actions { * @param {object} args * @param {string} args.video_id * - * @returns {Promise.<{ success: boolean; status_code: number; data: object; }>} + * @returns {Response} */ async thumbnails(args = {}) { const data = { @@ -396,7 +401,7 @@ class Actions { * @param {object} args * @param {string} args.input * - * @returns {Promise.<{ success: boolean; status_code: number; data: object; }>} + * @returns {Response} */ async geo(action, args = {}) { if (!this.#session.logged_in) @@ -420,7 +425,7 @@ class Actions { * @param {object} [args.action] * @param {string} [args.params] * - * @returns {Promise.<{ success: boolean; status_code: number; data: object; }>} + * @returns {Response} */ async flag(action, args) { if (!this.#session.logged_in) @@ -450,7 +455,7 @@ class Actions { * @param {string} action * @param {string} args.input * - * @returns {Promise.<{ success: boolean; status_code: number; data: object; }>} + * @returns {Response} */ async music(action, args) { const data = { @@ -471,7 +476,7 @@ class Actions { * @param {string} [args.ctoken] * @param {string} [args.client] * - * @returns {Promise.<{ success: boolean; status_code: number; data: object; }>} + * @returns {Response} */ async next(args = {}) { const data = {}; @@ -496,7 +501,7 @@ class Actions { * @param {string} id * @param {string} [cpn] * - * @returns {Promise.<{ success: boolean; status_code: number; data: object; }>} + * @returns {Response} */ async getVideoInfo(id, cpn) { const data = { @@ -529,7 +534,7 @@ class Actions { * @param {string} client * @param {string} input * - * @returns {Promise.<{ success: boolean; status_code: number; data: object; }>} + * @returns {Response} */ async getSearchSuggestions(client, query) { if (!['YOUTUBE', 'YTMUSIC'].includes(client)) @@ -561,7 +566,7 @@ class Actions { * @param {object} args * @param {string} args.input * - * @returns {Promise.<{ success: boolean; status_code: number; data: object; }>} + * @returns {Response} */ async getUserMentionSuggestions(args = {}) { if (!this.#session.logged_in) diff --git a/lib/core/Analytics.js b/lib/core/Analytics.js new file mode 100644 index 00000000..baa977b7 --- /dev/null +++ b/lib/core/Analytics.js @@ -0,0 +1,27 @@ +'use strict'; + +const Parser = require('../parser/contents'); + +/** @namespace */ +class Analytics { + #page; + + /** + * @param {object} response - API response. + * @constructor + */ + constructor(response) { + this.#page = Parser.parseResponse(response); + + const tab = this.#page.contents.tabs.get({ selected: true }); + const item = tab.content.contents.get({ target_id: null }); + + this.sections = item.contents; + } + + get page() { + return this.#page; + } +} + +module.exports = Analytics; \ No newline at end of file diff --git a/lib/core/History.js b/lib/core/History.js new file mode 100644 index 00000000..f13097cf --- /dev/null +++ b/lib/core/History.js @@ -0,0 +1,54 @@ +'use strict'; + +/** @namespace */ +class History { + #page; + #actions; + #continuation; + + /** + * @param {object} page - parsed data. + * @param {import('./Actions')} actions + * @param {boolean} is_continuation + * @constructor + */ + constructor(page, actions, is_continuation) { + this.#page = page; + this.#actions = actions; + + const tab = page.contents?.tabs.get({ selected: true }); + + const contents = is_continuation + && page.continuation_items + || tab.content.contents; + + this.sections = contents.map((section) => { + if (section.type == 'continuationItemRenderer') { + this.#continuation = section; + return; + } + + return { + title: section.header.title, + items: section.contents + } + }); + + this.has_continuation && this.sections.pop(); + } + + async getContinuation() { + const response = await this.#continuation.endpoint.call(this.#actions); + return new History(response.on_response_received_actions[0], this.#actions, true); + } + + get has_continuation() { + return !!this.#continuation; + } + + get page() { + return this.#page; + } +} + +module.exports = History; \ No newline at end of file diff --git a/lib/core/Library.js b/lib/core/Library.js new file mode 100644 index 00000000..9d1a7160 --- /dev/null +++ b/lib/core/Library.js @@ -0,0 +1,64 @@ +'use strict'; + +const Parser = require('../parser/contents'); +const History = require('./History'); + +/** @namespace */ +class Library { + #actions; + #page; + + /** + * @param {object} response - API response. + * @param {import('./Actions')} actions + * @constructor + */ + constructor(response, actions) { + this.#actions = actions; + this.#page = Parser.parseResponse(response); + + const tab = this.#page.contents.tabs.get({ selected: true }); + const shelves = tab.content.contents.map((section) => section.contents[0]); + + const stats = this.#page.contents.secondary_contents.items.get({ type: 'profileColumnStatsRenderer' }).items; + const user_info = this.#page.contents.secondary_contents.items.get({ type: 'profileColumnUserInfoRenderer' }); + + this.profile = { stats, user_info }; + + this.sections = shelves.map((shelf) => ({ + title: shelf.title.text, + items: shelf.content.items, + type: shelf.icon_type, + getAll: () => this.#getAll(shelf) + })); + } + + async #getAll(shelf) { + if (!shelf.menu?.top_level_buttons) + throw new Error('The ' + shelf.title.text + ' section doesn\'t have more items'); + + const button = await shelf.menu.top_level_buttons.get({ text: 'See all' }); + const page = await button.endpoint.call(this.#actions); + + switch (shelf.icon_type) { + case 'WATCH_HISTORY': + return new History(page, this.#actions); + case 'WATCH_LATER': + // TODO + break; + case 'LIKE': + // TODO + break; + case 'CONTENT_CUT': + // TODO + break; + default: + } + } + + get page() { + return this.#page; + } +} + +module.exports = Library; \ No newline at end of file diff --git a/lib/core/Search.js b/lib/core/Search.js new file mode 100644 index 00000000..fe4018e7 --- /dev/null +++ b/lib/core/Search.js @@ -0,0 +1,24 @@ +'use strict'; + +const Parser = require('../parser/contents'); + +/* TODO: Finish this */ + +/** @namespace */ +class Search { + #page; + + /** + * @param {object} response - API response. + * @param {import('./Actions')} actions + * @constructor + */ + constructor(response) { + this.#page = Parser.parseResponse(response.data); + + this.estimated_results = this.#page.estimated_results; + this.refinements = this.#page.refinements; + } +} + +module.exports = Search; \ No newline at end of file diff --git a/lib/core/SessionBuilder.js b/lib/core/SessionBuilder.js index 90311f65..cc166558 100644 --- a/lib/core/SessionBuilder.js +++ b/lib/core/SessionBuilder.js @@ -29,7 +29,11 @@ class SessionBuilder { */ constructor(config) { this.#config = config; - this.#axios = Axios.create({ proxy: this.#config.proxy, httpAgent: this.#config.httpAgent, httpsAgent: this.#config.httpsAgent }) + this.#axios = Axios.create({ + proxy: this.#config.proxy, + httpAgent: this.#config.http_agent, + httpsAgent: this.#config.https_agent + }); } async build() { @@ -116,7 +120,8 @@ class SessionBuilder { return Utils.getStringBetweenStrings(response.data, 'player\\/', '\\/'); } - + + /** @readonly */ get axios() { return this.#axios; } diff --git a/lib/core/VideoInfo.js b/lib/core/VideoInfo.js new file mode 100644 index 00000000..22dbc18b --- /dev/null +++ b/lib/core/VideoInfo.js @@ -0,0 +1,103 @@ +'use strict'; + +const Parser = require('../parser/contents'); +const { InnertubeError } = require('../utils/Utils'); + +/** namespace **/ +class VideoInfo { + #page; + #actions; + #player; + + /** + * @param {object} data - API response. + * @param {import('./Actions')} actions + * @param {import('./Player')} player + * @constructor + */ + constructor(data, actions, player) { + this.#actions = actions; + this.#player = player; + + const info = Parser.parseResponse(data[0]); + const next = Parser.parseResponse(data[1].data); + + this.#page = [ info, next]; + + if (info.playability_status.status === 'ERROR') + throw new InnertubeError('This video is unavailable', info.playability_status); + + /** + * @type {import('../parser/contents/classes/VideoDetails')} + */ + this.basic_info = { + ...info.video_details, + ...{ + /** + * Microformat is a bit redundant, so only + * a few things there are interesting to us. + */ + embed: info.microformat.embed, + channel: info.microformat.channel, + is_unlisted: info.microformat.is_unlisted, + is_family_safe: info.microformat.is_family_safe, + has_ypc_metadata: info.microformat.has_ypc_metadata + } + }; + + /** + * @type {import('../parser/contents/classes/VideoPrimaryInfo')} + */ + this.primary_info = next.contents.results.get({ type: 'videoPrimaryInfoRenderer' }); + + /** + * @type {import('../parser/contents/classes/VideoSecondaryInfo')} + */ + this.secondary_info = next.contents.results.get({ type: 'videoSecondaryInfoRenderer' }); + + /** + * @type {import('../parser/contents/classes/PlayerOverlay')} + */ + this.player_overlays = next.player_overlays; + + this.basic_info.like_count = this.primary_info.menu.top_level_buttons.get({ icon_type: 'LIKE' }).like_count; + this.basic_info.is_liked = this.primary_info.menu.top_level_buttons.get({ icon_type: 'LIKE' }).is_toggled; + this.basic_info.is_disliked = this.primary_info.menu.top_level_buttons.get({ icon_type: 'DISLIKE' }).is_toggled; + + this.streaming_data = info.streaming_data; + this.playability_status = info.playability_status; + + /** + * @type {import('../parser/contents/classes/PlayerAnnotationsExpanded')[]} + */ + this.annotations = info.annotations; + + /** + * @type {import('../parser/contents/classes/PlayerStoryboardSpec')} + */ + this.storyboards = info.storyboards; + + /** + * @type {import('../parser/contents/classes/Endscreen')} + */ + this.endscreen = info.endscreen; + + /** + * @type {import('../parser/contents/classes/CardCollection')} + */ + this.cards = info.cards; + + const comments_entry_point = next.contents.results.get({ target_id: 'comments-entry-point' }); + + /** + * @type {import('../parser/contents/classes/CommentsEntryPointHeader')} + */ + this.comments_entry_point_header = comments_entry_point?.contents.get({ type: 'commentsEntryPointHeaderRenderer' }) || {}; + } + + get page() { + return this.#page; + } +} + +module.exports = VideoInfo; \ No newline at end of file diff --git a/lib/deciphers/Signature.js b/lib/deciphers/Signature.js index 469f5000..f2f9ccac 100644 --- a/lib/deciphers/Signature.js +++ b/lib/deciphers/Signature.js @@ -9,6 +9,37 @@ class Signature { this.sig_decipher_sc = sig_decipher_sc; } + decipherBeta() { + let actions; + + const args = QueryString.parse(this.url); + const signature = args.s.split(''); + + const functions = this.#getFunctions(); + + /** + * Decides what function should be used to modify the + * the signature. + */ + while ((actions = Constants.SIG_REGEX.ACTIONS.exec(this.sig_decipher_sc)) !== null) { + const action = actions.groups; + switch (action.name) { + case functions[0]: + this.#reverse(signature); + break; + case functions[1]: + this.#splice(signature, action.param); + break; + case functions[2]: + this.#swap(signature, action.param); + break; + default: + } + } + + return signature.join(''); + } + /** * Deciphers signature. * @returns {string} diff --git a/lib/parser/contents/README.md b/lib/parser/contents/README.md new file mode 100644 index 00000000..d06f608d --- /dev/null +++ b/lib/parser/contents/README.md @@ -0,0 +1,23 @@ +# Parser + +## What +Sanitizes and standardizes InnerTube responses while maintaining the integrity of the data. This clever approach was initially implemented and suggested by [Wykerd](https://github.com/Wykerd) (See #44). + +Note: +This will eventually replace the old parser. + +## Methods + +#### parse(object: data) + +Responsible for parsing especially the `contents` property of the response object. + +##### Arguments + * `data` - the `contents` property. + +#### parseResponse(object: data) + +Unlike `parse`, this can be used to parse the entire response object. + +##### Arguments + * `data` - raw response from InnerTube. \ No newline at end of file diff --git a/lib/parser/contents/classes/AnalyticsMainAppKeyMetrics.js b/lib/parser/contents/classes/AnalyticsMainAppKeyMetrics.js new file mode 100644 index 00000000..6f534fc0 --- /dev/null +++ b/lib/parser/contents/classes/AnalyticsMainAppKeyMetrics.js @@ -0,0 +1,17 @@ +'use strict'; + +const DataModelSection = require('./DataModelSection'); + +class AnalyticsMainAppKeyMetrics { + type = 'analyticsMainAppKeyMetricsModel'; + + constructor(data) { + this.period = data.cardData.periodLabel; + + const metrics_data = data.cardData.sections[0].analyticsKeyMetricsData; + + this.sections = metrics_data.dataModel.sections.map((section) => new DataModelSection(section)); + } +} + +module.exports = AnalyticsMainAppKeyMetrics; \ No newline at end of file diff --git a/lib/parser/contents/classes/AnalyticsVideo.js b/lib/parser/contents/classes/AnalyticsVideo.js new file mode 100644 index 00000000..546e808e --- /dev/null +++ b/lib/parser/contents/classes/AnalyticsVideo.js @@ -0,0 +1,20 @@ +'use strict'; + +const Thumbnail = require('../Thumbnail'); + +class AnalyticsVideo { + type = 'video'; + + constructor(data) { + this.title = data.videoTitle; + this.metadata = { + views: data.videoDescription.split('·')[0].trim(), + published: data.videoDescription.split('·')[1].trim(), + thumbnails: new Thumbnail(data.thumbnailDetails).thumbnails, + duration: data.formattedLength, + is_short: data.isShort + } + } +} + +module.exports = AnalyticsVideo; \ No newline at end of file diff --git a/lib/parser/contents/classes/AnalyticsVodCarouselCard.js b/lib/parser/contents/classes/AnalyticsVodCarouselCard.js new file mode 100644 index 00000000..b9101f61 --- /dev/null +++ b/lib/parser/contents/classes/AnalyticsVodCarouselCard.js @@ -0,0 +1,14 @@ +'use strict'; + +const Video = require('./AnalyticsVideo'); + +class AnalyticsVodCarouselCard { + type = 'analyticsVodCarouselCardModel'; + + constructor(data) { + this.title = data.title; + this.videos = data.videoCarouselData.videos.map((video) => new Video(video)); + } +} + +module.exports = AnalyticsVodCarouselCard; \ No newline at end of file diff --git a/lib/parser/contents/classes/Author.js b/lib/parser/contents/classes/Author.js new file mode 100644 index 00000000..fe6d3fde --- /dev/null +++ b/lib/parser/contents/classes/Author.js @@ -0,0 +1,23 @@ +'use strict'; + +const Text = require('./Text'); +const Constants = require('../../../utils/Constants'); +const MetadataBadge = require('./MetadataBadge'); + +class Author { + constructor(data) { + const nav_text = new Text(data.nav_text); + const badges = data.badges && data.badges.map((badge) => new MetadataBadge(badge.metadataBadgeRenderer)); + + this.id = nav_text.runs[0].endpoint.browse?.id || 'N/A'; + this.url = nav_text.runs[0].endpoint.browse && `${Constants.URLS.YT_BASE}${nav_text.runs[0].endpoint.browse?.base_url}` || 'N/A'; + this.name = nav_text.text || 'N/A' + this.endpoint = nav_text.runs[0].endpoint; + this.badges = badges || []; + + this.is_verified = badges?.some((badge) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED') || false; + this.is_verified_artist = badges?.some((badge) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED_ARTIST') || false; + } +} + +module.exports = Author; \ No newline at end of file diff --git a/lib/parser/contents/classes/Button.js b/lib/parser/contents/classes/Button.js new file mode 100644 index 00000000..e02f8c5c --- /dev/null +++ b/lib/parser/contents/classes/Button.js @@ -0,0 +1,17 @@ +'use strict'; + +const Text = require('./Text'); +const NavigationEndpoint = require('./NavigationEndpoint'); + +class Button { + type = 'buttonRenderer'; + + constructor(data) { + this.text = new Text(data.text).toString(); + this.tooltip = data.tooltip || null; + this.icon_type = data.icon?.iconType || null; + this.endpoint = new NavigationEndpoint(data.navigationEndpoint || data.serviceEndpoint); + } +} + +module.exports = Button; \ No newline at end of file diff --git a/lib/parser/contents/classes/Card.js b/lib/parser/contents/classes/Card.js new file mode 100644 index 00000000..f84b13c3 --- /dev/null +++ b/lib/parser/contents/classes/Card.js @@ -0,0 +1,24 @@ +'use strict'; + +const Parser = require('..'); + +class Card { + type = 'cardRenderer'; + + constructor(data) { + this.teaser = Parser.parse(data.teaser); + this.content = Parser.parse(data.content); + + this.card_id = data.cardId; + this.feature = data.feature; + + this.cue_ranges = data.cueRanges.map((cr) => ({ + start_card_active_ms: cr.startCardActiveMs, + end_card_active_ms: cr.endCardActiveMs, + teaser_duration_ms: cr.teaserDurationMs, + icon_after_teaser_ms: cr.iconAfterTeaserMs + })); + } +} + +module.exports = Card; \ No newline at end of file diff --git a/lib/parser/contents/classes/CardCollection.js b/lib/parser/contents/classes/CardCollection.js new file mode 100644 index 00000000..f5f4311e --- /dev/null +++ b/lib/parser/contents/classes/CardCollection.js @@ -0,0 +1,16 @@ +'use strict'; + +const Parser = require('..'); +const Text = require('./Text'); + +class CardCollection { + type = 'cardCollectionRenderer'; + + constructor(data) { + this.cards = Parser.parse(data.cards); + this.header = new Text(data.headerText); + this.allow_teaser_dismiss = data.allowTeaserDismiss; + } +} + +module.exports = CardCollection; \ No newline at end of file diff --git a/lib/parser/contents/classes/ChannelMobileHeader.js b/lib/parser/contents/classes/ChannelMobileHeader.js new file mode 100644 index 00000000..aa03d838 --- /dev/null +++ b/lib/parser/contents/classes/ChannelMobileHeader.js @@ -0,0 +1,11 @@ +'use strict'; + +const Text = require('./Text'); + +class ChannelMobileHeader { + constructor(data) { + this.title = new Text(data.title); + } +} + +module.exports = ChannelMobileHeader; \ No newline at end of file diff --git a/lib/parser/contents/classes/ChannelThumbnailWithLink.js b/lib/parser/contents/classes/ChannelThumbnailWithLink.js new file mode 100644 index 00000000..0b662c0e --- /dev/null +++ b/lib/parser/contents/classes/ChannelThumbnailWithLink.js @@ -0,0 +1,16 @@ +'use strict'; + +const Thumbnail = require('./Thumbnail'); +const NavigationEndpoint = require('./NavigationEndpoint'); + +class ChannelThumbnailWithLink { + type = 'channelThumbnailWithLinkRenderer'; + + constructor(data) { + this.thumbnails = new Thumbnail(data.thumbnail).thumbnails; + this.endpoint = new NavigationEndpoint(data.navigationEndpoint); + this.label = data.accessibility.accessibilityData.label; + } +} + +module.exports = ChannelThumbnailWithLink; \ No newline at end of file diff --git a/lib/parser/contents/classes/ChildVideo.js b/lib/parser/contents/classes/ChildVideo.js new file mode 100644 index 00000000..d8de7ce3 --- /dev/null +++ b/lib/parser/contents/classes/ChildVideo.js @@ -0,0 +1,21 @@ +'use strict'; + +const NavigationEndpoint = require('./NavigationEndpoint'); +const Utils = require('../../../utils/Utils'); +const Text = require('./Text'); + +class ChildVideo { + type = 'childVideoRenderer'; + + constructor(data) { + this.id = data.videoId; + this.title = new Text(data.title); + this.duration = { + text: data.lengthText.simpleText, + seconds: Utils.timeToSeconds(data.lengthText.simpleText) + } + this.endpoint = new NavigationEndpoint(data.navigationEndpoint); + } +} + +module.exports = ChildVideo; \ No newline at end of file diff --git a/lib/parser/contents/classes/CommentsEntryPointHeader.js b/lib/parser/contents/classes/CommentsEntryPointHeader.js new file mode 100644 index 00000000..7c68e4a1 --- /dev/null +++ b/lib/parser/contents/classes/CommentsEntryPointHeader.js @@ -0,0 +1,17 @@ +'use strict'; + +const Text = require('./Text'); +const Thumbnail = require('./Thumbnail'); + +class CommentsEntryPointHeader { + type = 'commentsEntryPointHeaderRenderer'; + + constructor(data) { + this.header = new Text(data.headerText); + this.comment_count = new Text(data.commentCount); + this.teaser_avatar = new Thumbnail(data.teaserAvatar).thumbnails; + this.teaser_content = new Text(data.teaserContent); + } +} + +module.exports = CommentsEntryPointHeader; \ No newline at end of file diff --git a/lib/parser/contents/classes/ContinuationItem.js b/lib/parser/contents/classes/ContinuationItem.js new file mode 100644 index 00000000..38726e0e --- /dev/null +++ b/lib/parser/contents/classes/ContinuationItem.js @@ -0,0 +1,14 @@ +'use strict'; + +const NavigationEndpoint = require('./NavigationEndpoint'); + +class ContinuationItem { + type = 'continuationItemRenderer'; + + constructor(data) { + this.trigger = data.trigger; + this.endpoint = new NavigationEndpoint(data.continuationEndpoint); + } +} + +module.exports = ContinuationItem; \ No newline at end of file diff --git a/lib/parser/contents/classes/CtaGoToCreatorStudio.js b/lib/parser/contents/classes/CtaGoToCreatorStudio.js new file mode 100644 index 00000000..51b45db4 --- /dev/null +++ b/lib/parser/contents/classes/CtaGoToCreatorStudio.js @@ -0,0 +1,13 @@ +'use strict'; + +class CtaGoToCreatorStudio { + type = 'ctaGoToCreatorStudioModel'; + + constructor(data) { + this.title = data.buttonLabel; + this.use_new_specs = data.useNewSpecs; + // Is this even useful? + } +} + +module.exports = CtaGoToCreatorStudio; \ No newline at end of file diff --git a/lib/parser/contents/classes/DataModelSection.js b/lib/parser/contents/classes/DataModelSection.js new file mode 100644 index 00000000..105dc986 --- /dev/null +++ b/lib/parser/contents/classes/DataModelSection.js @@ -0,0 +1,22 @@ +'use strict'; + +class DataModelSection { + type = 'dataModelSection'; + + constructor(data) { + this.title = data.title; + this.subtitle = data.subtitle; + this.metric_value = data.metricValue; + this.comparison_indicator = data.comparisonIndicator; + + this.series_configuration = { + line_series: { + lines_data: data.seriesConfiguration.lineSeries.linesData, + domain_axis: data.seriesConfiguration.lineSeries.domainAxis, + measure_axis: data.seriesConfiguration.lineSeries.measureAxis + } + } + } +} + +module.exports = DataModelSection; \ No newline at end of file diff --git a/lib/parser/contents/classes/DownloadButton.js b/lib/parser/contents/classes/DownloadButton.js new file mode 100644 index 00000000..2007d7c4 --- /dev/null +++ b/lib/parser/contents/classes/DownloadButton.js @@ -0,0 +1,16 @@ +'use strict'; + +const NavigationEndpoint = require('./NavigationEndpoint'); + +class DownloadButton { + type = 'buttonRenderer'; + + constructor(data) { + this.style = data.style; + this.size = data.size; + this.endpoint = new NavigationEndpoint(data.command); + this.target_id = data.targetId; + } +} + +module.exports = DownloadButton; \ No newline at end of file diff --git a/lib/parser/contents/classes/Element.js b/lib/parser/contents/classes/Element.js new file mode 100644 index 00000000..b1eda6f4 --- /dev/null +++ b/lib/parser/contents/classes/Element.js @@ -0,0 +1,14 @@ +'use strict'; + +const Parser = require('..'); + +class Element { + type = 'elementRenderer'; + + constructor(data) { + const type = data.newElement.type.componentType; + return Parser.parse(type.model); + } +} + +module.exports = Element; \ No newline at end of file diff --git a/lib/parser/contents/classes/EndScreenPlaylist.js b/lib/parser/contents/classes/EndScreenPlaylist.js new file mode 100644 index 00000000..9cf9bea8 --- /dev/null +++ b/lib/parser/contents/classes/EndScreenPlaylist.js @@ -0,0 +1,20 @@ +'use strict'; + +const Text = require('./Text'); +const Thumbnail = require('./Thumbnail'); +const NavigationEndpoint = require('./NavigationEndpoint'); + +class EndScreenPlaylist { + type = 'endScreenPlaylistRenderer'; + + constructor(data) { + this.id = data.playlistId; + this.title = new Text(data.title); + this.author = new Text(data.longBylineText); + this.endpoint = new NavigationEndpoint(data.navigationEndpoint); + this.thumbnails = new Thumbnail(data.thumbnail).thumbnails; + this.video_count = new Text(data.videoCountText); + } +} + +module.exports = EndScreenPlaylist; \ No newline at end of file diff --git a/lib/parser/contents/classes/EndScreenVideo.js b/lib/parser/contents/classes/EndScreenVideo.js new file mode 100644 index 00000000..14bce7fb --- /dev/null +++ b/lib/parser/contents/classes/EndScreenVideo.js @@ -0,0 +1,27 @@ +'use strict'; + +const Parser = require('..'); +const Text = require('./Text'); +const Author = require('./Author'); +const Thumbnail = require('./Thumbnail'); +const NavigationEndpoint = require('./NavigationEndpoint'); + +class EndScreenVideo { + type = 'endScreenVideoRenderer'; + + constructor(data) { + this.id = data.videoId; + this.title = new Text(data.title); + this.thumbnails = new Thumbnail(data.thumbnail).thumbnails; + this.thumbnail_overlays = Parser.parse(data.thumbnailOverlays); + this.author = new Author({ nav_text: data.shortBylineText, badges: data.ownerBadges || data.badges }); + this.endpoint = new NavigationEndpoint(data.navigationEndpoint); + this.short_view_count_text = new Text(data.shortViewCountText); + this.duration = { + text: new Text(data.lengthText).toString(), + seconds: data.lengthInSeconds + } + } +} + +module.exports = EndScreenVideo; \ No newline at end of file diff --git a/lib/parser/contents/classes/Endscreen.js b/lib/parser/contents/classes/Endscreen.js new file mode 100644 index 00000000..b553533b --- /dev/null +++ b/lib/parser/contents/classes/Endscreen.js @@ -0,0 +1,14 @@ +'use strict'; + +const Parser = require('..'); + +class Endscreen { + type = 'endscreenRenderer'; + + constructor(data) { + this.elements = Parser.parse(data.elements); + this.start_ms = data.startMs; + } +} + +module.exports = Endscreen; \ No newline at end of file diff --git a/lib/parser/contents/classes/EndscreenElement.js b/lib/parser/contents/classes/EndscreenElement.js new file mode 100644 index 00000000..2bd25730 --- /dev/null +++ b/lib/parser/contents/classes/EndscreenElement.js @@ -0,0 +1,45 @@ +'use strict'; + +const Parser = require('..'); +const Thumbnail = require('./Thumbnail'); +const NavigationEndpoint = require('./NavigationEndpoint'); +const Text = require('./Text'); + +class EndscreenElement { + type = 'endscreenRenderer'; + + constructor(data) { + this.style = data.style; + + data.image && + (this.image = new Thumbnail(data.image).thumbnails); + + data.icon && + (this.icon = new Thumbnail(data.icon).thumbnails); + + data.metadata && + (this.metadata = new Text(data.metadata)); + + data.callToAction && + (this.call_to_action = new Text(data.callToAction)); + + data.hovercardButton && + (this.hovercard_button = Parser.parse(data.hovercardButton)); + + data.isSubscribe && + (this.is_subscribe = data.isSubscribe); + + this.title = new Text(data.title); + this.endpoint = new NavigationEndpoint(data.endpoint); + this.thumbnail_overlays = Parser.parse(data.thumbnailOverlays); + this.left = data.left; + this.width = data.width; + this.top = data.top; + this.aspect_ratio = data.aspectRatio; + this.start_ms = data.startMs; + this.end_ms = data.endMs; + this.id = data.id; + } +} + +module.exports = EndscreenElement; \ No newline at end of file diff --git a/lib/parser/contents/classes/FeedTabbedHeader.js b/lib/parser/contents/classes/FeedTabbedHeader.js new file mode 100644 index 00000000..1f3ed13a --- /dev/null +++ b/lib/parser/contents/classes/FeedTabbedHeader.js @@ -0,0 +1,11 @@ +'use strict'; + +const Text = require('./Text'); + +class FeedTabbedHeader { + constructor(data) { + this.title = new Text(data.title); + } +} + +module.exports = FeedTabbedHeader; \ No newline at end of file diff --git a/lib/parser/contents/classes/Format.js b/lib/parser/contents/classes/Format.js new file mode 100644 index 00000000..bae47fba --- /dev/null +++ b/lib/parser/contents/classes/Format.js @@ -0,0 +1,58 @@ +'use strict'; + +const NToken = require('../../../deciphers/NToken'); +const Signature = require('../../../deciphers/Signature'); +const QueryString = require('querystring'); + +class Format { + constructor(data) { + this.itag = data.itag; + this.mime_type = data.mimeType; + this.bitrate = data.bitrate; + this.average_bitrate = data.averageBitrate; + this.width = data.width || null; + this.height = data.height || null; + this.init_range = data.initRange; + this.index_range = data.indexRange; + this.last_modified = data.lastModified; + this.content_length = data.contentLength; + this.quality = data.quality; + this.quality_label = data.qualityLabel || null; + this.fps = data.fps || null; + this.url = data.url || null; + this.cipher = data.cipher || null; + this.signature_cipher = data.signatureCipher || null; + this.audio_quality = data.audioQuality; + this.approx_duration_ms = data.approxDurationMs; + this.audio_channels = data.audioChannels; + this.loudness_db = data.loudnessDb; + this.has_audio = !!data.audioBitrate || !!data.audioQuality; + this.has_video = !!data.qualityLabel; + } + + decipher(player) { + this.url = this.url || this.signature_cipher || this.cipher; + + const args = QueryString.parse(this.url); + const url_components = new URL(args.url); + + url_components.searchParams.set('ratebypass', 'yes'); + + if (this.signature_cipher || this.cipher) { + const signature = new Signature(this.url, player.signature_decipher).decipherBeta(); + + args.sp ? + url_components.searchParams.set(args.sp, signature) : + url_components.searchParams.set('signature', signature); + } + + if (url_components.searchParams.get('n')) { + const ntoken = new NToken(player.ntoken_decipher, url_components.searchParams.get('n')).transform(); + url_components.searchParams.set('n', ntoken); + } + + return url_components.toString(); + } +} + +module.exports = Format; \ No newline at end of file diff --git a/lib/parser/contents/classes/Grid.js b/lib/parser/contents/classes/Grid.js new file mode 100644 index 00000000..d0dd3bc2 --- /dev/null +++ b/lib/parser/contents/classes/Grid.js @@ -0,0 +1,16 @@ +'use strict'; + +const Parser = require('..'); + +class Grid { + type = 'gridRenderer'; + + constructor(data) { + this.items = Parser.parse(data.items); + this.is_collapsible = data.isCollapsible; + this.visible_row_count = data.visibleRowCount; + this.target_id = data.targetId; + } +} + +module.exports = Grid; \ No newline at end of file diff --git a/lib/parser/contents/classes/GridPlaylist.js b/lib/parser/contents/classes/GridPlaylist.js new file mode 100644 index 00000000..4cdc58ea --- /dev/null +++ b/lib/parser/contents/classes/GridPlaylist.js @@ -0,0 +1,25 @@ +'use strict'; + +const Text = require('./Text'); +const Parser = require('..'); +const Thumbnail = require('./Thumbnail'); +const PlaylistAuthor = require('./PlaylistAuthor'); +const NavigationEndpoint = require('./NavigationEndpoint'); + +class GridPlaylist { + type = 'gridPlaylistRenderer'; + + constructor(data) { + this.id = data.playlistId; + this.title = new Text(data.title); + this.author = new PlaylistAuthor({ nav_text: data.shortBylineText }); + this.badges = Parser.parse(data.ownerBadges); + this.endpoint = new NavigationEndpoint(data.navigationEndpoint); + this.thumbnails = new Thumbnail(data.thumbnail).thumbnails; + this.sidebar_thumbnails = [].concat(...data.sidebarThumbnails?.map((thumbnail) => new Thumbnail(thumbnail).thumbnails) || []) || null; + this.video_count = new Text(data.thumbnailText); + this.video_count_short_text = new Text(data.videoCountShortText); + } +} + +module.exports = GridPlaylist; \ No newline at end of file diff --git a/lib/parser/contents/classes/GridVideo.js b/lib/parser/contents/classes/GridVideo.js new file mode 100644 index 00000000..7fa830ee --- /dev/null +++ b/lib/parser/contents/classes/GridVideo.js @@ -0,0 +1,29 @@ +'use strict'; + +const Parser = require('..'); +const Text = require('./Text'); +const Thumbnail = require('./Thumbnail'); +const NavigationEndpoint = require('./NavigationEndpoint'); +const Author = require('./Author'); + +class GridVideo { + type = 'gridVideoRenderer'; + + constructor(data) { + const length_alt = data.thumbnailOverlays.find(overlay => overlay.hasOwnProperty('thumbnailOverlayTimeStatusRenderer'))?.thumbnailOverlayTimeStatusRenderer; + + this.id = data.videoId; + this.title = new Text(data.title); + this.thumbnails = new Thumbnail(data.thumbnail).thumbnails; + this.thumbnail_overlays = Parser.parse(data.thumbnailOverlays); + this.published = new Text(data.publishedTimeText); + this.duration = data.lengthText ? new Text(data.lengthText) : length_alt?.text ? new Text(length_alt.text) : ''; + this.author = new Author({ nav_text: data.shortBylineText, badges: data.ownerBadges }); + this.views = new Text(data.viewCountText); + this.short_view_count = new Text(data.shortViewCountText); + this.endpoint = new NavigationEndpoint(data.navigationEndpoint); + this.menu = Parser.parse(data.menu); + } +} + +module.exports = GridVideo; \ No newline at end of file diff --git a/lib/parser/contents/classes/HorizontalCardList.js b/lib/parser/contents/classes/HorizontalCardList.js new file mode 100644 index 00000000..f20fac30 --- /dev/null +++ b/lib/parser/contents/classes/HorizontalCardList.js @@ -0,0 +1,16 @@ +'use strict'; + +const Parser = require('..'); + +class HorizontalCardList { + type = 'horizontalCardListRenderer'; + + constructor(data) { + this.cards = Parser.parse(data.cards); + this.header = Parser.parse(data.header); + this.previous_button = Parser.parse(data.previousButton); + this.next_button = Parser.parse(data.nextButton); + } +} + +module.exports = HorizontalCardList; \ No newline at end of file diff --git a/lib/parser/contents/classes/ItemSection.js b/lib/parser/contents/classes/ItemSection.js new file mode 100644 index 00000000..cfb096e2 --- /dev/null +++ b/lib/parser/contents/classes/ItemSection.js @@ -0,0 +1,15 @@ +'use strict'; + +const Parser = require('..'); + +class ItemSection { + type = 'itemSectionRenderer'; + + constructor(data) { + this.header = Parser.parse(data.header); + this.target_id = data.targetId || data.sectionIdentifier || null; + this.contents = Parser.parse(data.contents); + } +} + +module.exports = ItemSection; \ No newline at end of file diff --git a/lib/parser/contents/classes/ItemSectionHeader.js b/lib/parser/contents/classes/ItemSectionHeader.js new file mode 100644 index 00000000..dddd9ce2 --- /dev/null +++ b/lib/parser/contents/classes/ItemSectionHeader.js @@ -0,0 +1,11 @@ +'use strict'; + +const Text = require('./Text'); + +class ItemSectionHeader { + constructor(data) { + this.title = new Text(data.title); + } +} + +module.exports = ItemSectionHeader; \ No newline at end of file diff --git a/lib/parser/contents/classes/Menu.js b/lib/parser/contents/classes/Menu.js new file mode 100644 index 00000000..6af849a3 --- /dev/null +++ b/lib/parser/contents/classes/Menu.js @@ -0,0 +1,15 @@ +'use strict'; + +const Parser = require('..'); + +class Menu { + type = 'menuRenderer'; + + constructor(data) { + this.items = Parser.parse(data.items) || []; + this.top_level_buttons = Parser.parse(data.topLevelButtons) || []; + this.label = data.accessibility?.accessibilityData?.label || null; + } +} + +module.exports = Menu; \ No newline at end of file diff --git a/lib/parser/contents/classes/MenuNavigationItem.js b/lib/parser/contents/classes/MenuNavigationItem.js new file mode 100644 index 00000000..2433a3e0 --- /dev/null +++ b/lib/parser/contents/classes/MenuNavigationItem.js @@ -0,0 +1,13 @@ +'use strict'; + +const Button = require('./Button'); + +class MenuNavigationItem extends Button { + type = 'menuNavigationItemRenderer'; + + constructor(data) { + super(data); + } +} + +module.exports = MenuNavigationItem; \ No newline at end of file diff --git a/lib/parser/contents/classes/MenuServiceItem.js b/lib/parser/contents/classes/MenuServiceItem.js new file mode 100644 index 00000000..fab1d8b0 --- /dev/null +++ b/lib/parser/contents/classes/MenuServiceItem.js @@ -0,0 +1,13 @@ +'use strict'; + +const Button = require('./Button'); + +class MenuServiceItem extends Button { + type = 'menuServiceItemRenderer'; + + constructor(data) { + super(data); + } +} + +module.exports = MenuServiceItem; \ No newline at end of file diff --git a/lib/parser/contents/classes/MetadataBadge.js b/lib/parser/contents/classes/MetadataBadge.js new file mode 100644 index 00000000..2270fb10 --- /dev/null +++ b/lib/parser/contents/classes/MetadataBadge.js @@ -0,0 +1,11 @@ +'use strict'; + +class MetadataBadge { + constructor(data) { + this.icon_type = data.icon?.iconType; + this.style = data.style; + this.tooltip = data.tooltip; + } +} + +module.exports = MetadataBadge; \ No newline at end of file diff --git a/lib/parser/contents/classes/MetadataRow.js b/lib/parser/contents/classes/MetadataRow.js new file mode 100644 index 00000000..9f41abb9 --- /dev/null +++ b/lib/parser/contents/classes/MetadataRow.js @@ -0,0 +1,14 @@ +'use strict'; + +const Text = require('./Text'); + +class MetadataRow { + type = 'metadataRowRenderer'; + + constructor(data) { + this.title = new Text(data.title); + this.contents = data.contents.map((content) => new Text(content)); + } +} + +module.exports = MetadataRow; \ No newline at end of file diff --git a/lib/parser/contents/classes/MetadataRowContainer.js b/lib/parser/contents/classes/MetadataRowContainer.js new file mode 100644 index 00000000..396c4182 --- /dev/null +++ b/lib/parser/contents/classes/MetadataRowContainer.js @@ -0,0 +1,14 @@ +'use strict'; + +const Parser = require('..'); + +class MetadataRowContainer { + type = 'metadataRowContainerRenderer'; + + constructor(data) { + this.rows = Parser.parse(data.rows); + this.collapsed_item_count = data.collapsedItemCount; + } +} + +module.exports = MetadataRowContainer; \ No newline at end of file diff --git a/lib/parser/contents/classes/MetadataRowHeader.js b/lib/parser/contents/classes/MetadataRowHeader.js new file mode 100644 index 00000000..6d0ed1da --- /dev/null +++ b/lib/parser/contents/classes/MetadataRowHeader.js @@ -0,0 +1,14 @@ +'use strict'; + +const Text = require('./Text'); + +class MetadataRowHeader { + type = 'metadataRowHeaderRenderer'; + + constructor(data) { + this.content = new Text(data.content); + this.has_divider_line = data.hasDividerLine; + } +} + +module.exports = MetadataRowHeader; \ No newline at end of file diff --git a/lib/parser/contents/classes/Mix.js b/lib/parser/contents/classes/Mix.js new file mode 100644 index 00000000..21950f5b --- /dev/null +++ b/lib/parser/contents/classes/Mix.js @@ -0,0 +1,13 @@ +'use strict'; + +const Playlist = require('./Playlist'); + +class Mix extends Playlist { + type = 'radioRenderer'; + + constructor(data) { + super(data); + } +} + +module.exports = Mix; \ No newline at end of file diff --git a/lib/parser/contents/classes/MovingThumbnail.js b/lib/parser/contents/classes/MovingThumbnail.js new file mode 100644 index 00000000..b3c08c1d --- /dev/null +++ b/lib/parser/contents/classes/MovingThumbnail.js @@ -0,0 +1,19 @@ +'use strict'; + +const Thumbnail = require('./Thumbnail'); + +class MovingThumbnail { + type = 'movingThumbnailRenderer'; + + #data; + + constructor(data) { + this.#data = data; + } + + get thumbnails() { + return this.#data.movingThumbnailDetails.thumbnails.map((thumbnail) => new Thumbnail(thumbnail)).sort((a, b) => b.width - a.width); + } +} + +module.exports = MovingThumbnail; \ No newline at end of file diff --git a/lib/parser/contents/classes/NavigationEndpoint.js b/lib/parser/contents/classes/NavigationEndpoint.js new file mode 100644 index 00000000..65051003 --- /dev/null +++ b/lib/parser/contents/classes/NavigationEndpoint.js @@ -0,0 +1,135 @@ +'use strict'; + +const Parser = require('..'); + +class NavigationEndpoint { + type = 'navigationEndpoint'; + + constructor(data) { + data?.serviceEndpoint && + (data = data.serviceEndpoint); + + this.metadata = { + url: data?.commandMetadata?.webCommandMetadata.url || null, + page_type: data?.commandMetadata?.webCommandMetadata.webPageType || 'N/A', + api_url: data?.commandMetadata?.webCommandMetadata.apiUrl || null, + send_post: data?.commandMetadata?.webCommandMetadata.sendPost || null + } + + if (data?.browseEndpoint) { + this.browse = { + id: data?.browseEndpoint?.browseId || null, + params: data?.browseEndpoint.params || null, + base_url: data?.browseEndpoint?.canonicalBaseUrl || null + }; + } + + if (data?.watchEndpoint) { + this.watch = { + video_id: data?.watchEndpoint?.videoId, + playlist_id: data?.watchEndpoint.playlistId || null, + params: data?.watchEndpoint.params || null, + index: data?.watchEndpoint.index || null, + supported_onesie_config: data?.watchEndpoint?.watchEndpointSupportedOnesieConfig + }; + } + + if (data?.subscribeEndpoint) { + this.subscribe = { + channel_ids: data.subscribeEndpoint.channelIds, + params: data.subscribeEndpoint.params + } + } + + if (data?.unsubscribeEndpoint) { + this.unsubscribe = { + channel_ids: data.unsubscribeEndpoint.channelIds, + params: data.unsubscribeEndpoint.params + } + } + + if (data?.likeEndpoint) { + this.like = { + status: data.likeEndpoint.status, + target: { video_id: data.likeEndpoint.target.videoId }, + remove_like_params: data.likeEndpoint?.removeLikeParams + } + } + + if (data?.offlineVideoEndpoint) { + this.offline_video = { + video_id: data.offlineVideoEndpoint.videoId, + on_add_command: { + get_download_action: { + video_id: data.offlineVideoEndpoint.videoId, + params: data.offlineVideoEndpoint.onAddCommand.getDownloadActionCommand.params, + } + } + } + } + + if (data?.continuationCommand) { + this.continuation = { + request: data?.continuationCommand?.request || null, + token: data?.continuationCommand?.token || null + }; + } + + if (data?.feedbackEndpoint) { + this.feedback = { + token: data.feedbackEndpoint.feedbackToken + } + } + + if (data?.watchPlaylistEndpoint) { + this.watch_playlist = { + playlist_id: data.watchPlaylistEndpoint?.playlistId + } + } + + if (data?.playlistEditEndpoint) { + this.playlist_edit = { + playlist_id: data.playlistEditEndpoint.playlistId, + actions: data.playlistEditEndpoint.actions.map((item) => ({ + action: item.action, + removed_video_id: item.removedVideoId + })) + } + } + + if (data?.addToPlaylistServiceEndpoint) { + this.add_to_playlist = { + video_id: data.addToPlaylistServiceEndpoint.videoId + } + } + + if (data?.getReportFormEndpoint) { + this.get_report_form = { + params: data.getReportFormEndpoint.params + } + } + } + + async call(actions) { + if (this.continuation) { + switch (this.continuation.request) { + case 'CONTINUATION_REQUEST_TYPE_BROWSE': + const response = await actions.browse(this.continuation.token, { is_ctoken: true }); + return Parser.parseResponse(response.data); + default: + } + } + + if (this.browse) { + const args = {}; + + this.browse.params && + (args.params = this.browse.params); + + const response = await actions.browse(this.browse.id, args); + return Parser.parseResponse(response.data); + } + } +} + +module.exports = NavigationEndpoint; \ No newline at end of file diff --git a/lib/parser/contents/classes/PlayerAnnotationsExpanded.js b/lib/parser/contents/classes/PlayerAnnotationsExpanded.js new file mode 100644 index 00000000..f7a9810c --- /dev/null +++ b/lib/parser/contents/classes/PlayerAnnotationsExpanded.js @@ -0,0 +1,24 @@ +'use strict'; + +const Parser = require('..'); +const Thumbnail = require('./Thumbnail'); +const NavigationEndpoint = require('./NavigationEndpoint'); + +class PlayerAnnotationsExpanded { + type = 'playerAnnotationsExpandedRenderer'; + + constructor(data) { + this.featured_channel = { + start_time_ms: data.featuredChannel.startTimeMs, + end_time_ms: data.featuredChannel.endTimeMs, + watermark: new Thumbnail(data.featuredChannel.watermark).thumbnails, + channel_name: data.featuredChannel.channelName, + endpoint: new NavigationEndpoint(data.featuredChannel.navigationEndpoint), + subscribe_button: Parser.parse(data.featuredChannel.subscribeButton) + } + this.allow_swipe_dismiss = data.allowSwipeDismiss; + this.annotation_id = data.annotationId; + } +} + +module.exports = PlayerAnnotationsExpanded; \ No newline at end of file diff --git a/lib/parser/contents/classes/PlayerMicroformat.js b/lib/parser/contents/classes/PlayerMicroformat.js new file mode 100644 index 00000000..00383406 --- /dev/null +++ b/lib/parser/contents/classes/PlayerMicroformat.js @@ -0,0 +1,37 @@ +'use strict'; + +const Text = require('./Text'); +const Thumbnail = require('./Thumbnail'); + +class PlayerMicroformat { + type = 'playerMicroformatRenderer'; + + constructor(data) { + this.title = new Text(data.title); + this.description = new Text(data.description); + this.thumbnails = new Thumbnail(data.thumbnail).thumbnails; + this.embed = { + iframe_url: data.embed.iframeUrl, + flash_url: data.embed.flashUrl, + flash_secure_url: data.embed.flashSecureUrl, + width: data.embed.width, + height: data.embed.height + } + this.length_seconds = parseInt(data.lengthSeconds); + this.channel = { + id: data.externalChannelId, + name: data.ownerChannelName, + url: data.ownerProfileUrl + } + this.is_family_safe = data.isFamilySafe; + this.is_unlisted = data.isUnlisted; + this.has_ypc_metadata = data.hasYpcMetadata; + this.view_count = parseInt(data.viewCount); + this.category = data.category; + this.publish_date = data.publishDate; + this.upload_date = data.uploadDate; + this.available_countries = data.availableCountries; + } +} + +module.exports = PlayerMicroformat; \ No newline at end of file diff --git a/lib/parser/contents/classes/PlayerOverlay.js b/lib/parser/contents/classes/PlayerOverlay.js new file mode 100644 index 00000000..6ba94805 --- /dev/null +++ b/lib/parser/contents/classes/PlayerOverlay.js @@ -0,0 +1,15 @@ +'use strict'; + +const Parser = require('..'); + +class PlayerOverlay { + type = 'playerOverlayRenderer'; + + constructor(data) { + this.end_screen = Parser.parse(data.endScreen); + this.share_button = Parser.parse(data.shareButton); + this.add_to_menu = Parser.parse(data.addToMenu); + } +} + +module.exports = PlayerOverlay; \ No newline at end of file diff --git a/lib/parser/contents/classes/PlayerStoryboardSpec.js b/lib/parser/contents/classes/PlayerStoryboardSpec.js new file mode 100644 index 00000000..319f5268 --- /dev/null +++ b/lib/parser/contents/classes/PlayerStoryboardSpec.js @@ -0,0 +1,44 @@ +'use strict'; + +class PlayerStoryboardSpec { + type = 'playerStoryboardSpecRenderer'; + + constructor(data) { + const parts = data.spec.split('|'); + const url = new URL(parts.shift()); + + this.boards = parts.map((part, i) => { + let [ + thumbnail_width, + thumbnail_height, + thumbnail_count, + columns, + rows, + interval, + name, + sigh, + ] = part.split('#'); + + url.searchParams.set('sigh', sigh); + + thumbnail_count = parseInt(thumbnail_count, 10); + columns = parseInt(columns, 10); + rows = parseInt(rows, 10); + + const storyboard_count = Math.ceil(thumbnail_count / (columns * rows)); + + return { + template_url: url.toString().replace('$L', i).replace('$N', name), + thumbnail_width: parseInt(thumbnail_width, 10), + thumbnail_height: parseInt(thumbnail_height, 10), + thumbnail_count, + interval: parseInt(interval, 10), + columns, + rows, + storyboard_count, + }; + }); + } +} + +module.exports = PlayerStoryboardSpec; \ No newline at end of file diff --git a/lib/parser/contents/classes/Playlist.js b/lib/parser/contents/classes/Playlist.js new file mode 100644 index 00000000..ae0e1087 --- /dev/null +++ b/lib/parser/contents/classes/Playlist.js @@ -0,0 +1,35 @@ +'use strict'; + +const Text = require('./Text'); +const Parser = require('..'); +const Thumbnail = require('./Thumbnail'); +const NavigationEndpoint = require('./NavigationEndpoint'); +const PlaylistAuthor = require('./PlaylistAuthor'); + +class Playlist { + type = 'playlistRenderer'; + + constructor(data) { + this.id = data.playlistId; + this.title = new Text(data.title); + + this.author = data.shortBylineText.simpleText && + new Text(data.shortBylineText) || + new PlaylistAuthor({ nav_text: data.shortBylineText, badges: data.ownerBadges }); + + this.badges = Parser.parse(data.ownerBadges); + this.endpoint = new NavigationEndpoint(data.navigationEndpoint); + + this.thumbnail = { + thumbnails: new Thumbnail(data.thumbnail).thumbnails, + sampled_thumbnail_color: data.thumbnail?.sampledThumbnailColor + }; + + this.video_count = new Text(data.thumbnailText); + this.video_count_short_text = new Text(data.videoCountShortText); + + this.first_videos = Parser.parse(data.videos); + } +} + +module.exports = Playlist; \ No newline at end of file diff --git a/lib/parser/contents/classes/PlaylistAuthor.js b/lib/parser/contents/classes/PlaylistAuthor.js new file mode 100644 index 00000000..36e04ad1 --- /dev/null +++ b/lib/parser/contents/classes/PlaylistAuthor.js @@ -0,0 +1,15 @@ +'use strict'; + +const Author = require('./Author'); + +class PlaylistAuthor extends Author { + constructor(data) { + super(data); + + delete this.badges; + delete this.is_verified; + delete this.is_verified_artist; + } +} + +module.exports = PlaylistAuthor; \ No newline at end of file diff --git a/lib/parser/contents/classes/PlaylistVideo.js b/lib/parser/contents/classes/PlaylistVideo.js new file mode 100644 index 00000000..45745cab --- /dev/null +++ b/lib/parser/contents/classes/PlaylistVideo.js @@ -0,0 +1,27 @@ +'use strict'; + +const Text = require('./Text'); +const Thumbnail = require('./Thumbnail'); +const PlaylistAuthor = require('./PlaylistAuthor'); +const NavigationEndpoint = require('./NavigationEndpoint'); + +class PlaylistVideo { + type = 'playlistVideoRenderer'; + + constructor(data) { + this.id = data.videoId; + this.index = new Text(data.index); + this.title = new Text(data.title); + this.author = new PlaylistAuthor({ nav_text: data.shortBylineText }); + this.thumbnails = new Thumbnail(data.thumbnail).thumbnails; + this.set_video_id = data?.setVideoId; + this.endpoint = new NavigationEndpoint(data.navigationEndpoint); + this.is_playable = data.isPlayable; + this.duration = { + text: new Text(data.lengthText).text, + seconds: parseInt(data.lengthSeconds) + } + } +} + +module.exports = PlaylistVideo; \ No newline at end of file diff --git a/lib/parser/contents/classes/PlaylistVideoList.js b/lib/parser/contents/classes/PlaylistVideoList.js new file mode 100644 index 00000000..059016d7 --- /dev/null +++ b/lib/parser/contents/classes/PlaylistVideoList.js @@ -0,0 +1,16 @@ +'use strict'; + +const Parser = require('..'); + +class PlaylistVideoList { + type = 'playlistVideoListRenderer'; + + constructor(data) { + this.id = data.playlistId; + this.is_editable = data.isEditable; + this.can_reorder = data.canReorder; + this.videos = Parser.parse(data.contents); + } +} + +module.exports = PlaylistVideoList; \ No newline at end of file diff --git a/lib/parser/contents/classes/ProfileColumn.js b/lib/parser/contents/classes/ProfileColumn.js new file mode 100644 index 00000000..0baafcf6 --- /dev/null +++ b/lib/parser/contents/classes/ProfileColumn.js @@ -0,0 +1,13 @@ +'use strict'; + +const Parser = require('..'); + +class ProfileColumn { + type = 'profileColumnRenderer'; + + constructor(data) { + this.items = Parser.parse(data.items); + } +} + +module.exports = ProfileColumn; \ No newline at end of file diff --git a/lib/parser/contents/classes/ProfileColumnStats.js b/lib/parser/contents/classes/ProfileColumnStats.js new file mode 100644 index 00000000..48db5f43 --- /dev/null +++ b/lib/parser/contents/classes/ProfileColumnStats.js @@ -0,0 +1,13 @@ +'use strict'; + +const Parser = require('..'); + +class ProfileColumnStats { + type = 'profileColumnStatsRenderer'; + + constructor(data) { + this.items = Parser.parse(data.items); + } +} + +module.exports = ProfileColumnStats; \ No newline at end of file diff --git a/lib/parser/contents/classes/ProfileColumnStatsEntry.js b/lib/parser/contents/classes/ProfileColumnStatsEntry.js new file mode 100644 index 00000000..0e14636b --- /dev/null +++ b/lib/parser/contents/classes/ProfileColumnStatsEntry.js @@ -0,0 +1,14 @@ +'use strict'; + +const Text = require('./Text'); + +class ProfileColumnStatsEntry { + type = 'profileColumnStatsEntryRenderer'; + + constructor(data) { + this.label = new Text(data.label); + this.value = new Text(data.value); + } +} + +module.exports = ProfileColumnStatsEntry; \ No newline at end of file diff --git a/lib/parser/contents/classes/ProfileColumnUserInfo.js b/lib/parser/contents/classes/ProfileColumnUserInfo.js new file mode 100644 index 00000000..fdc7cbf6 --- /dev/null +++ b/lib/parser/contents/classes/ProfileColumnUserInfo.js @@ -0,0 +1,15 @@ +'use strict'; + +const Text = require('./Text'); +const Thumbnail = require('./Thumbnail'); + +class ProfileColumnUserInfo { + type = 'profileColumnUserInfoRenderer'; + + constructor(data) { + this.title = new Text(data.title); + this.thumbnails = new Thumbnail(data.thumbnail).thumbnails; + } +} + +module.exports = ProfileColumnUserInfo; \ No newline at end of file diff --git a/lib/parser/contents/classes/RichListHeader.js b/lib/parser/contents/classes/RichListHeader.js new file mode 100644 index 00000000..a4ad3f71 --- /dev/null +++ b/lib/parser/contents/classes/RichListHeader.js @@ -0,0 +1,12 @@ +'use strict'; + +const Text = require('./Text'); + +class RichListHeader { + constructor(data) { + this.title = new Text(data.title); + this.type = data.icon.iconType; + } +} + +module.exports = RichListHeader; \ No newline at end of file diff --git a/lib/parser/contents/classes/SearchRefinementCard.js b/lib/parser/contents/classes/SearchRefinementCard.js new file mode 100644 index 00000000..3003ffa3 --- /dev/null +++ b/lib/parser/contents/classes/SearchRefinementCard.js @@ -0,0 +1,17 @@ +'use strict'; + +const NavigationEndpoint = require('./NavigationEndpoint'); +const Thumbnail = require('./Thumbnail'); +const Text = require('./Text'); + +class SearchRefinementCard { + type = 'searchRefinementCardRenderer'; + + constructor(data) { + this.thumbnail = new Thumbnail(data.thumbnail).thumbnails; + this.endpoint = new NavigationEndpoint(data.searchEndpoint); + this.query = new Text(data.query); + } +} + +module.exports = SearchRefinementCard; \ No newline at end of file diff --git a/lib/parser/contents/classes/SectionList.js b/lib/parser/contents/classes/SectionList.js new file mode 100644 index 00000000..33ce0345 --- /dev/null +++ b/lib/parser/contents/classes/SectionList.js @@ -0,0 +1,14 @@ +'use strict'; + +const Parser = require('..'); + +class SectionList { + type = 'sectionListRenderer'; + + constructor(data) { + this.target_id = data.targetId || null; + this.contents = Parser.parse(data.contents); + } +} + +module.exports = SectionList; \ No newline at end of file diff --git a/lib/parser/contents/classes/Shelf.js b/lib/parser/contents/classes/Shelf.js new file mode 100644 index 00000000..d1848c96 --- /dev/null +++ b/lib/parser/contents/classes/Shelf.js @@ -0,0 +1,19 @@ +'use strict'; + +const Text = require('./Text'); +const Parser = require('..'); +const NavigationEndpoint = require('./NavigationEndpoint'); + +class Shelf { + type = 'shelfRenderer'; + + constructor(data) { + this.title = new Text(data.title); + this.endpoint = new NavigationEndpoint(data.endpoint); + this.content = Parser.parse(data.content) || []; + this.icon_type = data.icon?.iconType || null; + this.menu = Parser.parse(data.menu); + } +} + +module.exports = Shelf; \ No newline at end of file diff --git a/lib/parser/contents/classes/SimpleCardTeaser.js b/lib/parser/contents/classes/SimpleCardTeaser.js new file mode 100644 index 00000000..4e45dca9 --- /dev/null +++ b/lib/parser/contents/classes/SimpleCardTeaser.js @@ -0,0 +1,14 @@ +'use strict'; + +const Text = require('./Text'); + +class SimpleCardTeaser { + type = 'simpleCardTeaserRenderer'; + + constructor(data) { + this.message = new Text(data.message); + this.prominent = data.prominent; + } +} + +module.exports = SimpleCardTeaser; \ No newline at end of file diff --git a/lib/parser/contents/classes/SingleColumnBrowseResults.js b/lib/parser/contents/classes/SingleColumnBrowseResults.js new file mode 100644 index 00000000..5220f5f7 --- /dev/null +++ b/lib/parser/contents/classes/SingleColumnBrowseResults.js @@ -0,0 +1,19 @@ +'use strict'; + +const Parser = require('..'); + +class SingleColumnBrowseResults { + type = 'singleColumnBrowseResultsRenderer'; + + #data; + + constructor(data) { + this.#data = data; + } + + get tabs() { + return Parser.parse(this.#data.tabs); + } +} + +module.exports = SingleColumnBrowseResults; \ No newline at end of file diff --git a/lib/parser/contents/classes/SubscribeButton.js b/lib/parser/contents/classes/SubscribeButton.js new file mode 100644 index 00000000..3c979bce --- /dev/null +++ b/lib/parser/contents/classes/SubscribeButton.js @@ -0,0 +1,24 @@ +'use strict'; + +const Parser = require('..'); +const Text = require('./Text'); +const NavigationEndpoint = require('./NavigationEndpoint'); + +class SubscribeButton { + type = 'subscribeButtonRenderer'; + + constructor(data) { + this.title = new Text(data.buttonText); + this.subscribed = data.subscribed; + this.enabled = data.enabled; + this.type = data.type; + this.channel_id = data.channelId; + this.show_preferences = data.showPreferences; + this.subscribed_text = new Text(data.subscribedButtonText); + this.unsubscribed_text = new Text(data.unsubscribedButtonText); + this.notification_preference_button = Parser.parse(data.notificationPreferenceButton); + this.endpoint = new NavigationEndpoint(data.serviceEndpoints?.[0] || data.onSubscribeEndpoints?.[0]); + } +} + +module.exports = SubscribeButton; \ No newline at end of file diff --git a/lib/parser/contents/classes/SubscriptionNotificationToggleButton.js b/lib/parser/contents/classes/SubscriptionNotificationToggleButton.js new file mode 100644 index 00000000..b2a970ee --- /dev/null +++ b/lib/parser/contents/classes/SubscriptionNotificationToggleButton.js @@ -0,0 +1,20 @@ +'use strict'; + +const Parser = require('..'); + +class SubscriptionNotificationToggleButton { + type = 'subscriptionNotificationToggleButtonRenderer'; + + constructor(data) { + this.states = data.states.map((state) => ({ + id: state.stateId, + next_id: state.nextStateId, + state: Parser.parse(state.state) + })); + + this.current_state_id = data.currentStateId; + this.target_id = data.targetId; + } +} + +module.exports = SubscriptionNotificationToggleButton; \ No newline at end of file diff --git a/lib/parser/contents/classes/Tab.js b/lib/parser/contents/classes/Tab.js new file mode 100644 index 00000000..cd64c7b4 --- /dev/null +++ b/lib/parser/contents/classes/Tab.js @@ -0,0 +1,17 @@ +'use strict'; + +const Parser = require('..'); +const NavigationEndpoint = require('./NavigationEndpoint'); + +class Tab { + type = 'tabRenderer'; + + constructor(data) { + this.title = data.title || 'N/A'; + this.selected = data.selected; + this.endpoint = new NavigationEndpoint(data.endpoint); + this.content = Parser.parse(data.content); + } +} + +module.exports = Tab; \ No newline at end of file diff --git a/lib/parser/contents/classes/Text.js b/lib/parser/contents/classes/Text.js new file mode 100644 index 00000000..af5c24a0 --- /dev/null +++ b/lib/parser/contents/classes/Text.js @@ -0,0 +1,20 @@ +'use strict'; + +const TextRun = require('./TextRun'); + +class Text { + constructor(data) { + if (data?.hasOwnProperty('runs')) { + this.text = data.runs.map((run) => run.text).join(''); + this.runs = data.runs.map((run) => new TextRun(run)); + } else { + this.text = data?.simpleText || 'N/A'; + } + } + + toString() { + return this.text; + } +} + +module.exports = Text; \ No newline at end of file diff --git a/lib/parser/contents/classes/TextRun.js b/lib/parser/contents/classes/TextRun.js new file mode 100644 index 00000000..a87568f2 --- /dev/null +++ b/lib/parser/contents/classes/TextRun.js @@ -0,0 +1,12 @@ +'use strict'; + +const NavigationEndpoint = require('./NavigationEndpoint'); + +class TextRun { + constructor(data) { + this.text = data.text; + this.endpoint = data.navigationEndpoint && new NavigationEndpoint(data.navigationEndpoint) || {}; + } +} + +module.exports = TextRun; \ No newline at end of file diff --git a/lib/parser/contents/classes/Thumbnail.js b/lib/parser/contents/classes/Thumbnail.js new file mode 100644 index 00000000..07f3d885 --- /dev/null +++ b/lib/parser/contents/classes/Thumbnail.js @@ -0,0 +1,21 @@ +'use strict'; + +class Thumbnail { + type = 'thumbnail'; + + #data; + + constructor(data) { + this.#data = data; + + this.url = data.url; + this.width = data.width; + this.height = data.height; + } + + get thumbnails() { + return this.#data.thumbnails.map((thumbnail) => new Thumbnail(thumbnail)).sort((a, b) => b.width - a.width); + } +} + +module.exports = Thumbnail; \ No newline at end of file diff --git a/lib/parser/contents/classes/ThumbnailOverlayNowPlaying.js b/lib/parser/contents/classes/ThumbnailOverlayNowPlaying.js new file mode 100644 index 00000000..9a9368c9 --- /dev/null +++ b/lib/parser/contents/classes/ThumbnailOverlayNowPlaying.js @@ -0,0 +1,13 @@ +'use strict'; + +const Text = require('./Text'); + +class ThumbnailOverlayNowPlaying { + type = 'thumbnailOverlayNowPlayingRenderer'; + + constructor(data) { + this.text = new Text(data.text).text; + } +} + +module.exports = ThumbnailOverlayNowPlaying; \ No newline at end of file diff --git a/lib/parser/contents/classes/ThumbnailOverlayPinking.js b/lib/parser/contents/classes/ThumbnailOverlayPinking.js new file mode 100644 index 00000000..54e8c78c --- /dev/null +++ b/lib/parser/contents/classes/ThumbnailOverlayPinking.js @@ -0,0 +1,11 @@ +'use strict'; + +class ThumbnailOverlayPinking { + type = 'thumbnailOverlayPinkingRenderer'; + + constructor(data) { + this.hack = data.hack; + } +} + +module.exports = ThumbnailOverlayPinking; \ No newline at end of file diff --git a/lib/parser/contents/classes/ThumbnailOverlayResumePlayback.js b/lib/parser/contents/classes/ThumbnailOverlayResumePlayback.js new file mode 100644 index 00000000..42bd69fb --- /dev/null +++ b/lib/parser/contents/classes/ThumbnailOverlayResumePlayback.js @@ -0,0 +1,11 @@ +'use strict'; + +class ThumbnailOverlayResumePlayback { + type = 'thumbnailOverlayResumePlaybackRenderer'; + + constructor(data) { + this.percent_duration_watched = data.percentDurationWatched; + } +} + +module.exports = ThumbnailOverlayResumePlayback; \ No newline at end of file diff --git a/lib/parser/contents/classes/ThumbnailOverlayTimeStatus.js b/lib/parser/contents/classes/ThumbnailOverlayTimeStatus.js new file mode 100644 index 00000000..87010dcb --- /dev/null +++ b/lib/parser/contents/classes/ThumbnailOverlayTimeStatus.js @@ -0,0 +1,13 @@ +'use strict'; + +const Text = require('./Text'); + +class ThumbnailOverlayTimeStatus { + type = 'thumbnailOverlayTimeStatusRenderer'; + + constructor(data) { + this.text = new Text(data.text).text; + } +} + +module.exports = ThumbnailOverlayTimeStatus; \ No newline at end of file diff --git a/lib/parser/contents/classes/ThumbnailOverlayToggleButton.js b/lib/parser/contents/classes/ThumbnailOverlayToggleButton.js new file mode 100644 index 00000000..3d1eb445 --- /dev/null +++ b/lib/parser/contents/classes/ThumbnailOverlayToggleButton.js @@ -0,0 +1,26 @@ +'use strict'; + +const NavigationEndpoint = require('./NavigationEndpoint'); + +class ThumbnailOverlayToggleButton { + type = 'thumbnailOverlayToggleButtonRenderer'; + + constructor(data) { + this.is_toggled = data.isToggled || null; + + this.icon_type = { + toggled: data.toggledIcon.iconType, + untoggled: data.untoggledIcon.iconType + } + + this.tooltip = { + toggled: data.toggledTooltip, + untoggled: data.untoggledTooltip + } + + this.toggled_endpoint = new NavigationEndpoint(data.toggledServiceEndpoint); + this.untoggled_endpoint = new NavigationEndpoint(data.untoggledServiceEndpoint); + } +} + +module.exports = ThumbnailOverlayToggleButton; \ No newline at end of file diff --git a/lib/parser/contents/classes/ToggleButton.js b/lib/parser/contents/classes/ToggleButton.js new file mode 100644 index 00000000..2e6197ce --- /dev/null +++ b/lib/parser/contents/classes/ToggleButton.js @@ -0,0 +1,28 @@ +'use strict'; + +const Text = require('./Text'); +const NavigationEndpoint = require('./NavigationEndpoint'); + +class ToggleButton { + type = 'toggleButtonRenderer'; + + constructor(data) { + this.text = new Text(data.defaultText); + this.toggled_text = new Text(data.toggledText); + this.tooltip = data.defaultTooltip; + this.toggled_tooltip = data.toggledTooltip; + this.is_toggled = data.isToggled; + this.is_disabled = data.isDisabled; + this.icon_type = data.defaultIcon.iconType; + + this.icon_type == 'LIKE' && + (this.like_count = parseInt(data.defaultText.accessibility.accessibilityData.label.replace(/\D/g, ''))) && + (this.short_like_count = new Text(data.defaultText).toString()); + + this.endpoint = new NavigationEndpoint(data.toggledServiceEndpoint); + this.button_id = data.toggleButtonSupportedData.toggleButtonIdData.id; + this.target_id = data.targetId; + } +} + +module.exports = ToggleButton; \ No newline at end of file diff --git a/lib/parser/contents/classes/Tooltip.js b/lib/parser/contents/classes/Tooltip.js new file mode 100644 index 00000000..18c26b9e --- /dev/null +++ b/lib/parser/contents/classes/Tooltip.js @@ -0,0 +1,26 @@ +'use strict'; + +const Text = require('./Text'); +const NavigationEndpoint = require('./NavigationEndpoint'); + +class Tooltip { + type = 'tooltipRenderer'; + + constructor(data) { + this.promo_config = { + promo_id: data.promoConfig.promoId, + impression_endpoints: data.promoConfig.impressionEndpoints + .map((endpoint) => new NavigationEndpoint(endpoint)), + accept: new NavigationEndpoint(data.promoConfig.acceptCommand), + dismiss: new NavigationEndpoint(data.promoConfig.dismissCommand), + } + + this.target_id = data.targetId; + this.details = new Text(data.detailsText); + this.suggested_position = data.suggestedPosition.type; + this.dismiss_stratedy = data.dismissStrategy.type; + this.dwell_time_ms = parseInt(data.dwellTimeMs); + } +} + +module.exports = Tooltip; \ No newline at end of file diff --git a/lib/parser/contents/classes/TwoColumnBrowseResults.js b/lib/parser/contents/classes/TwoColumnBrowseResults.js new file mode 100644 index 00000000..f50cbb98 --- /dev/null +++ b/lib/parser/contents/classes/TwoColumnBrowseResults.js @@ -0,0 +1,23 @@ +'use strict'; + +const Parser = require('..'); + +class TwoColumnBrowseResults { + type = 'twoColumnBrowseResultsRenderer'; + + #data; + + constructor(data) { + this.#data = data; + } + + get tabs() { + return Parser.parse(this.#data.tabs); + } + + get secondary_contents() { + return Parser.parse(this.#data.secondaryContents); + } +} + +module.exports = TwoColumnBrowseResults; \ No newline at end of file diff --git a/lib/parser/contents/classes/TwoColumnSearchResults.js b/lib/parser/contents/classes/TwoColumnSearchResults.js new file mode 100644 index 00000000..4756bf1a --- /dev/null +++ b/lib/parser/contents/classes/TwoColumnSearchResults.js @@ -0,0 +1,23 @@ +'use strict'; + +const Parser = require('..'); + +class TwoColumnSearchResults { + type = 'twoColumnSearchResultsRenderer'; + + #data; + + constructor(data) { + this.#data = data; + } + + get primary_contents() { + return Parser.parse(this.#data.primaryContents); + } + + get secondary_contents() { + return Parser.parse(this.#data.secondaryContents); + } +} + +module.exports = TwoColumnSearchResults; \ No newline at end of file diff --git a/lib/parser/contents/classes/TwoColumnWatchNextResults.js b/lib/parser/contents/classes/TwoColumnWatchNextResults.js new file mode 100644 index 00000000..6eb74aca --- /dev/null +++ b/lib/parser/contents/classes/TwoColumnWatchNextResults.js @@ -0,0 +1,23 @@ +'use strict'; + +const Parser = require('..'); + +class TwoColumnWatchNextResults { + type = 'twoColumnWatchNextResults'; + + #data; + + constructor(data) { + this.#data = data; + } + + get results() { + return Parser.parse(this.#data.results.results.contents); + } + + get secondary_results() { + return Parser.parse(this.#data.secondaryResults.secondaryResults.results.results); + } +} + +module.exports = TwoColumnWatchNextResults; \ No newline at end of file diff --git a/lib/parser/contents/classes/VerticalList.js b/lib/parser/contents/classes/VerticalList.js new file mode 100644 index 00000000..a5298869 --- /dev/null +++ b/lib/parser/contents/classes/VerticalList.js @@ -0,0 +1,16 @@ +'use strict'; + +const Parser = require('..'); +const Text = require('./Text'); + +class VerticalList { + type = 'verticalListRenderer'; + + constructor(data) { + this.items = Parser.parse(data.items); + this.collapsed_item_count = data.collapsedItemCount; + this.collapsed_state_button_text = new Text(data.collapsedStateButtonText); + } +} + +module.exports = VerticalList; \ No newline at end of file diff --git a/lib/parser/contents/classes/Video.js b/lib/parser/contents/classes/Video.js new file mode 100644 index 00000000..1d78386c --- /dev/null +++ b/lib/parser/contents/classes/Video.js @@ -0,0 +1,44 @@ +'use strict'; + +const Parser = require('..'); +const Text = require('./Text'); +const Author = require('./Author'); +const Thumbnail = require('./Thumbnail'); +const NavigationEndpoint = require('./NavigationEndpoint'); +const Utils = require('../../../utils/Utils'); + +class Video { + type = 'videoRenderer'; + + constructor(data) { + const overlay_time_status = data.thumbnailOverlays + .find((overlay) => overlay.thumbnailOverlayTimeStatusRenderer) + .thumbnailOverlayTimeStatusRenderer.text; + + this.id = data.videoId; + this.title = new Text(data.title); + this.description = new Text(data.descriptionSnippet || data.description); + this.snippets = data.detailedMetadataSnippets?.map((snippet) => ({ + text: new Text(snippet.snippetText), + hover_text: new Text(snippet.snippetHoverText), + })) || []; + this.thumbnails = new Thumbnail(data.thumbnail).thumbnails; + this.thumbnail_overlays = Parser.parse(data.thumbnailOverlays); + this.moving_thumbnails = Parser.parse(data.richThumbnail)?.thumbnails || []; + this.channel_thumbnail = Parser.parse(data.channelThumbnailSupportedRenderers); + this.author = new Author({ nav_text: data.ownerText, badges: data.ownerBadges || data.badges }); + this.endpoint = new NavigationEndpoint(data.navigationEndpoint); + this.published = new Text(data.publishedTimeText); + this.view_count_text = new Text(data.viewCountText); + this.short_view_count_text = new Text(data.shortViewCountText); + this.duration = { + text: data.lengthText && new Text(data.lengthText).text || new Text(overlay_time_status).text, + seconds: Utils.timeToSeconds(data.lengthText && new Text(data.lengthText).text || new Text(overlay_time_status).text) + }; + this.show_action_menu = data.showActionMenu; + this.is_watched = data.isWatched || null; + this.menu = Parser.parse(data.menu); + } +} + +module.exports = Video; \ No newline at end of file diff --git a/lib/parser/contents/classes/VideoDetails.js b/lib/parser/contents/classes/VideoDetails.js new file mode 100644 index 00000000..f4fc3598 --- /dev/null +++ b/lib/parser/contents/classes/VideoDetails.js @@ -0,0 +1,25 @@ +'use strict'; + +const Thumbnail = require('./Thumbnail'); + +class VideoDetails { + constructor(data) { + this.id = data.videoId; + this.title = data.title; + this.duration = parseInt(data.lengthSeconds); + this.keywords = data.keywords; + this.channel_id = data.channelId; + this.is_owner_viewing = data.isOwnerViewing; + this.description = data.shortDescription; + this.is_crawlable = data.isCrawlable; + this.thumbnails = new Thumbnail(data.thumbnail).thumbnails; + this.allow_ratings = data.allowRatings; + this.view_count = parseInt(data.viewCount); + this.author = data.author; + this.is_private = data.isPrivate; + this.is_unplugged_corpus = data.isUnpluggedCorpus; + this.is_live_content = data.isLiveContent; + } +} + +module.exports = VideoDetails; \ No newline at end of file diff --git a/lib/parser/contents/classes/VideoInfoCardContent.js b/lib/parser/contents/classes/VideoInfoCardContent.js new file mode 100644 index 00000000..36090ba3 --- /dev/null +++ b/lib/parser/contents/classes/VideoInfoCardContent.js @@ -0,0 +1,20 @@ +'use strict'; + +const Text = require('./Text'); +const Thumbnail = require('./Thumbnail'); +const NavigationEndpoint = require('./NavigationEndpoint'); + +class VideoInfoCardContent { + type = 'videoInfoCardContentRenderer'; + + constructor(data) { + this.title = new Text(data.videoTitle); + this.channel_name = new Text(data.channelName); + this.view_count = new Text(data.viewCountText); + this.video_thumbnails = new Thumbnail(data.videoThumbnail).thumbnails; + this.duration = new Text(data.lengthString); + this.endpoint = new NavigationEndpoint(data.action); + } +} + +module.exports = VideoInfoCardContent; \ No newline at end of file diff --git a/lib/parser/contents/classes/VideoOwner.js b/lib/parser/contents/classes/VideoOwner.js new file mode 100644 index 00000000..42e58152 --- /dev/null +++ b/lib/parser/contents/classes/VideoOwner.js @@ -0,0 +1,23 @@ +'use strict'; + +const Parser = require('..'); +const Text = require('./Text'); +const Thumbnail = require('./Thumbnail'); +const NavigationEndpoint = require('./NavigationEndpoint'); + +class VideoOwner { + type = 'videoOwnerRenderer'; + + constructor(data) { + this.name = new Text(data.title); + this.thumbnails = new Thumbnail(data.thumbnail).thumbnails; + this.subscription_button = data.subscriptionButton; + this.endpoint = new NavigationEndpoint(data.navigationEndpoint); + this.subscriber_count = new Text(data.subscriberCountText); + this.badges = Parser.parse(data.badges); + this.is_verified = this.badges?.some((badge) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED') || false; + this.is_verified_artist = this.badges?.some((badge) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED_ARTIST') || false; + } +} + +module.exports = VideoOwner; \ No newline at end of file diff --git a/lib/parser/contents/classes/VideoPrimaryInfo.js b/lib/parser/contents/classes/VideoPrimaryInfo.js new file mode 100644 index 00000000..b306d82c --- /dev/null +++ b/lib/parser/contents/classes/VideoPrimaryInfo.js @@ -0,0 +1,19 @@ +'use strict'; + +const Parser = require('..'); +const Text = require('./Text'); + +class VideoPrimaryInfo { + type = 'videoPrimaryInfoRenderer'; + + constructor(data) { + this.title = new Text(data.title); + this.super_title_link = new Text(data.superTitleLink); + this.view_count = new Text(data.viewCount.videoViewCountRenderer.viewCount); + this.short_view_count = new Text(data.viewCount.videoViewCountRenderer.shortViewCount); + this.published = new Text(data.dateText); + this.menu = Parser.parse(data.videoActions); + } +} + +module.exports = VideoPrimaryInfo; \ No newline at end of file diff --git a/lib/parser/contents/classes/VideoSecondaryInfo.js b/lib/parser/contents/classes/VideoSecondaryInfo.js new file mode 100644 index 00000000..9a10c962 --- /dev/null +++ b/lib/parser/contents/classes/VideoSecondaryInfo.js @@ -0,0 +1,21 @@ +'use strict'; + +const Parser = require('..'); +const Text = require('./Text'); + +class VideoSecondaryInfo { + type = 'videoSecondaryInfoRenderer'; + + constructor(data) { + this.owner = Parser.parse(data.owner); + this.description = new Text(data.description); + this.subscribe_button = Parser.parse(data.subscribeButton); + this.metadata = Parser.parse(data.metadataRowContainer); + this.show_more_text = data.showMoreText; + this.show_less_text = data.showLessText; + this.default_expanded = data.defaultExpanded; + this.description_collapsed_lines = data.descriptionCollapsedLines; + } +} + +module.exports = VideoSecondaryInfo; \ No newline at end of file diff --git a/lib/parser/contents/classes/WatchNextEndScreen.js b/lib/parser/contents/classes/WatchNextEndScreen.js new file mode 100644 index 00000000..a027d452 --- /dev/null +++ b/lib/parser/contents/classes/WatchNextEndScreen.js @@ -0,0 +1,13 @@ +'use strict'; + +const Parser = require('..'); +const Text = require('./Text'); + +class WatchNextEndScreen { + constructor(data) { + this.results = Parser.parse(data.results); + this.title = new Text(data.title).toString(); + } +} + +module.exports = WatchNextEndScreen; \ No newline at end of file diff --git a/lib/parser/contents/index.js b/lib/parser/contents/index.js new file mode 100644 index 00000000..82544386 --- /dev/null +++ b/lib/parser/contents/index.js @@ -0,0 +1,157 @@ +'use strict'; + +const { InnertubeError } = require('../../utils/Utils'); +const Format = require('./classes/Format'); +const VideoDetails = require('./classes/VideoDetails'); + +/** @namespace */ +class AppendContinuationItemsAction { + type = 'appendContinuationItemsAction'; + + constructor (data) { + this.continuation_items = Parser.parse(data.continuationItems); + } +} + +/** @namespace */ +class Parser { + static parseResponse(data) { + return { + contents: Parser.parse(data.contents), + on_response_received_actions: data.onResponseReceivedActions && Parser.parseRR(data.onResponseReceivedActions) || null, + on_response_received_endpoints: data.onResponseReceivedEndpoints && Parser.parseRR(data.onResponseReceivedEndpoints) || null, + metadata: Parser.parse(data.metadata), + header: Parser.parse(data.header), + /** @type {import('./classes/PlayerMicroformat')} **/ + microformat: data.microformat && Parser.parse(data.microformat), + sidebar: Parser.parse(data.sidebar), + overlay: Parser.parse(data.overlay), + refinements: data.refinements || null, + estimated_results: data.estimatedResults || null, + player_overlays: Parser.parse(data.playerOverlays), + playability_status: data.playabilityStatus && { + /** @type {number} */ + status: data.playabilityStatus.status, + /** @type {boolean} */ + embeddable: data.playabilityStatus.playableInEmbed || null, + /** @type {string} */ + reason: data.reason || '' + }, + streaming_data: data.streamingData && { + expires: new Date(Date.now() + parseInt(data.streamingData.expiresInSeconds) * 1000), + /** @type {import('./classes/Format')[]} */ + formats: Parser.parseFormats(data.streamingData.formats), + /** @type {import('./classes/Format')[]} */ + adaptive_formats: Parser.parseFormats(data.streamingData.adaptiveFormats), + dash_manifest_url: data.streamingData?.dashManifestUrl || null, + dls_manifest_url: data.streamingData?.dashManifestUrl || null, + }, + captions: Parser.parse(data.captions), + video_details: data.videoDetails && new VideoDetails(data.videoDetails), + annotations: Parser.parse(data.annotations), + storyboards: Parser.parse(data.storyboards), + /** @type {import('./classes/Endscreen')} */ + endscreen: Parser.parse(data.endscreen), + /** @type {import('./classes/CardCollection')} */ + cards: Parser.parse(data.cards), + } + } + + static parseRR(actions) { + return actions.map((action) => { + if (action.appendContinuationItemsAction) + return new AppendContinuationItemsAction(action.appendContinuationItemsAction); + }).filter((item) => item); + } + + static parseFormats(formats) { + return formats?.map((format) => new Format(format)) || []; + } + + static parse(data) { + if (!data) + return null; + + if (Array.isArray(data)) { + let results = []; + + for (let item of data) { + const keys = Object.keys(item); + const class_name = this.sanitizeClassName(keys[0]); + + try { + const TargetClass = require('./classes/' + class_name); + results.push(new TargetClass(item[keys[0]])); + } catch { + console.warn( + new InnertubeError(class_name + ' not found!\n' + + 'This is a bug, please report it at ' + require('../../../package.json').bugs.url, + data[keys[0]]) + ); + return null; + } + } + + /** + * Creates a trap to intercept property access + * and add helper functions. + */ + const proxy = new Proxy(results, { + get (target, prop) { + if (prop == 'get') { + /** + * Returns the first object to match the rule. + * @name get + * @param {object} rule + */ + return (rule) => target + .find((obj) => { + const rule_keys = Reflect.ownKeys(rule); + return rule_keys.some((key) => obj[key] === rule[key]); + }); + } + + if (prop == 'findAll') { + /** + * Returns all objects that match the rule. + * @name findAll + * @param {object} rule + */ + return (rule) => target + .filter((obj) => { + const rule_keys = Reflect.ownKeys(rule); + return rule_keys.some((key) => obj[key] === rule[key]); + }); + } + + return Reflect.get(...arguments); + } + }); + + return proxy; + } else { + const keys = Object.keys(data); + const class_name = this.sanitizeClassName(keys[0]); + + try { + const TargetClass = require('./classes/' + class_name); + return new TargetClass(data[keys[0]]); + } catch { + console.warn( + new InnertubeError(class_name + ' not found!\n' + + 'This is a bug, please report it at ' + require('../../../package.json').bugs.url, + data[keys[0]]) + ); + return null; + } + } + } + + static sanitizeClassName(input) { + return (input.charAt(0).toUpperCase() + input.slice(1)) + .replace(/Renderer|Model/g, '') + .replace(/Radio/g, 'Mix').trim(); + } +} + +module.exports = Parser; \ No newline at end of file diff --git a/lib/utils/Request.js b/lib/utils/Request.js index c4005442..69f2ba3d 100644 --- a/lib/utils/Request.js +++ b/lib/utils/Request.js @@ -12,6 +12,7 @@ class Request { */ constructor(session) { this.session = session; + this.instance = Axios.create({ ...session.axios.defaults, baseURL: Constants.URLS.YT_BASE_API + session.version, @@ -111,6 +112,7 @@ class Request { case 'ANDROID': ctx.client.originalUrl = Constants.URLS.YT_BASE; ctx.client.clientVersion = Constants.CLIENTS.ANDROID.VERSION; + ctx.client.clientFormFactor = 'SMALL_FORM_FACTOR'; ctx.client.clientName = Constants.CLIENTS.ANDROID.NAME; break; default: diff --git a/lib/utils/Utils.js b/lib/utils/Utils.js index ccbbcdbb..66bece05 100644 --- a/lib/utils/Utils.js +++ b/lib/utils/Utils.js @@ -94,7 +94,7 @@ function generateSidAuth(sid) { } /** - * Generates a random string with a given length. + * Generates a random string with the given length. * * @param {number} length * @returns {string} @@ -118,6 +118,7 @@ function generateRandomString(length) { */ function timeToSeconds(time) { let params = time.split(':'); + return parseInt(({ 3: +params[0] * 3600 + +params[1] * 60 + +params[2], 2: +params[0] * 60 + +params[1],