diff --git a/package.json b/package.json index 1573d488..ec5a201a 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "build:parser-map": "node ./scripts/build-parser-json.js", "bundle:browser": "npx tsc --module esnext && npx esbuild ./dist/browser.js --banner:js=\"/* eslint-disable */\" --bundle --target=chrome58 --keep-names --format=esm --sourcemap --define:global=globalThis --outfile=./bundle/browser.js --platform=browser", "bundle:browser:prod": "npm run bundle:browser -- --outfile=./bundle/browser.min.js --minify", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run build", + "watch": "npx tsc --watch" }, "repository": { "type": "git", diff --git a/src/Innertube.ts b/src/Innertube.ts index 6bd7dffe..e6df36b3 100644 --- a/src/Innertube.ts +++ b/src/Innertube.ts @@ -1,43 +1,47 @@ + import Session, { SessionOptions } from './core/Session'; -import AccountManager from './core/AccountManager'; -import PlaylistManager from './core/PlaylistManager'; -import InteractionManager from './core/InteractionManager'; + import Search from './parser/youtube/Search'; -import VideoInfo, { DownloadOptions, FormatOptions } from './parser/youtube/VideoInfo'; import Channel from './parser/youtube/Channel'; import Playlist from './parser/youtube/Playlist'; import Library from './parser/youtube/Library'; import History from './parser/youtube/History'; import Comments from './parser/youtube/Comments'; import NotificationsMenu from './parser/youtube/NotificationsMenu'; +import VideoInfo, { DownloadOptions, FormatOptions } from './parser/youtube/VideoInfo'; + +import Feed from './core/Feed'; import YTMusic from './core/Music'; +import AccountManager from './core/AccountManager'; +import PlaylistManager from './core/PlaylistManager'; +import InteractionManager from './core/InteractionManager'; import FilterableFeed from './core/FilterableFeed'; import TabbedFeed from './core/TabbedFeed'; -import Feed from './core/Feed'; import Constants from './utils/Constants'; -import { throwIfMissing, generateRandomString } from './utils/Utils'; import Proto from './proto/index'; +import { throwIfMissing, generateRandomString } from './utils/Utils'; + export type InnertubeConfig = SessionOptions export interface SearchFilters { - /** - * Filter videos by upload date, can be: any | last_hour | today | this_week | this_month | this_year - */ - upload_date?: 'any' | 'last_hour' | 'today' | 'this_week' | 'this_month' | 'this_year'; - /** - * Filter results by type, can be: any | video | channel | playlist | movie - */ - type?: 'any' | 'video' | 'channel' | 'playlist' | 'movie'; - /** - * Filter videos by duration, can be: any | short | medium | long - */ - duration?: 'any' | 'short' | 'medium' | 'long'; - /** - * Filter video results by order, can be: relevance | rating | upload_date | view_count - */ - sort_by?: 'relevance' | 'rating' | 'upload_date' | 'view_count'; - } + /** + * Filter videos by upload date, can be: any | last_hour | today | this_week | this_month | this_year + */ + upload_date?: 'any' | 'last_hour' | 'today' | 'this_week' | 'this_month' | 'this_year'; + /** + * Filter results by type, can be: any | video | channel | playlist | movie + */ + type?: 'any' | 'video' | 'channel' | 'playlist' | 'movie'; + /** + * Filter videos by duration, can be: any | short | medium | long + */ + duration?: 'any' | 'short' | 'medium' | 'long'; + /** + * Filter video results by order, can be: relevance | rating | upload_date | view_count + */ + sort_by?: 'relevance' | 'rating' | 'upload_date' | 'view_count'; +} class Innertube { session; @@ -46,6 +50,7 @@ class Innertube { interact; music; actions; + constructor(session: Session) { this.session = session; this.account = new AccountManager(this.session.actions); @@ -54,29 +59,38 @@ class Innertube { this.music = new YTMusic(this.session); this.actions = this.session.actions; } + static async create(config: InnertubeConfig = {}) { return new Innertube(await Session.create(config)); } + /** * Retrieves video info. */ async getInfo(video_id: string) { throwIfMissing({ video_id }); + const cpn = generateRandomString(16); + const initial_info = this.actions.getVideoInfo(video_id, cpn); const continuation = this.actions.next({ video_id }); + const response = await Promise.all([ initial_info, continuation ]); return new VideoInfo(response, this.actions, this.session.player, cpn); } + /** * Retrieves basic video info. */ async getBasicInfo(video_id: string) { throwIfMissing({ video_id }); + const cpn = generateRandomString(16); const response = await this.actions.getVideoInfo(video_id, cpn); + return new VideoInfo([ response ], this.actions, this.session.player, cpn); } + /** * Searches a given query. * @param query - search query. @@ -87,12 +101,14 @@ class Innertube { const response = await this.actions.search({ query, filters }); return new Search(this.actions, response.data); } + /** * Retrieves search suggestions for a given query. * @param query - the search query. */ async getSearchSuggestions(query: string): Promise { throwIfMissing({ query }); + const url = new URL(`${Constants.URLS.YT_SUGGESTIONS}search`); url.searchParams.set('q', query); url.searchParams.set('hl', this.session.context.client.hl); @@ -103,13 +119,14 @@ class Innertube { url.searchParams.set('oe', 'UTF'); const response = await this.session.http.fetch(url); - const response_data = await response.text(); const data = JSON.parse(response_data.replace(')]}\'', '')); const suggestions = data[1].map((suggestion: any) => suggestion[0]); + return suggestions; } + /** * Retrieves comments for a video. * @param video_id - the video id. @@ -117,12 +134,15 @@ class Innertube { */ async getComments(video_id: string, sort_by?: 'TOP_COMMENTS' | 'NEWEST_FIRST') { throwIfMissing({ video_id }); + const payload = Proto.encodeCommentsSectionParams(video_id, { sort_by: sort_by || 'TOP_COMMENTS' }); + const response = await this.actions.next({ ctoken: payload }); return new Comments(this.actions, response.data); } + /** * Retrieves YouTube's home feed (aka recommendations). */ @@ -130,6 +150,7 @@ class Innertube { const response = await this.actions.browse('FEwhat_to_watch'); return new FilterableFeed(this.actions, response.data); } + /** * Returns the account's library. */ @@ -137,6 +158,7 @@ class Innertube { const response = await this.actions.browse('FElibrary'); return new Library(response.data, this.actions); } + /** * Retrieves watch history. * Which can also be achieved with {@link getLibrary}. @@ -145,6 +167,7 @@ class Innertube { const response = await this.actions.browse('FEhistory'); return new History(this.actions, response.data); } + /** * Retrieves trending content. */ @@ -152,6 +175,7 @@ class Innertube { const response = await this.actions.browse('FEtrending'); return new TabbedFeed(this.actions, response.data); } + /** * Retrieves subscriptions feed. */ @@ -159,6 +183,7 @@ class Innertube { const response = await this.actions.browse('FEsubscriptions'); return new Feed(this.actions, response.data); } + /** * Retrieves contents for a given channel. * @param id - channel id @@ -168,6 +193,7 @@ class Innertube { const response = await this.actions.browse(id); return new Channel(this.actions, response.data); } + /** * Retrieves notifications. */ @@ -175,6 +201,7 @@ class Innertube { const response = await this.actions.notifications('get_notification_menu'); return new NotificationsMenu(this.actions, response.data); } + /** * Retrieves unseen notifications count. */ @@ -182,6 +209,7 @@ class Innertube { const response = await this.actions.notifications('get_unseen_count'); return response.data.unseenCount; } + /** * Retrieves the contents of a given playlist. * @param playlist_id - the id of the playlist. @@ -191,6 +219,7 @@ class Innertube { const response = await this.actions.browse(`VL${playlist_id.replace(/VL/g, '')}`); return new Playlist(this.actions, response.data); } + /** * An alternative to {@link download}. * Returns deciphered streaming data. @@ -201,6 +230,7 @@ class Innertube { const info = await this.getBasicInfo(video_id); return info.chooseFormat(options); } + /** * Downloads a given video. If you only need the direct download link take a look at {@link getStreamingData}. * @@ -212,4 +242,5 @@ class Innertube { return info.download(options); } } -export default Innertube; + +export default Innertube; \ No newline at end of file diff --git a/src/core/AccountManager.ts b/src/core/AccountManager.ts index d9717c57..b4ee468d 100644 --- a/src/core/AccountManager.ts +++ b/src/core/AccountManager.ts @@ -11,6 +11,7 @@ class AccountManager { constructor(actions: Actions) { this.#actions = actions; + this.channel = { /** * Edits channel name. @@ -26,41 +27,36 @@ class AccountManager { */ getBasicAnalytics: () => this.getAnalytics() }; + this.settings = { notifications: { /** * Notify about activity from the channels you're subscribed to. - * * @param option - ON | OFF */ setSubscriptions: (option: boolean) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SUBSCRIPTIONS, 'SPaccount_notifications', option), /** * Recommended content notifications. - * * @param option - ON | OFF */ setRecommendedVideos: (option: boolean) => this.#setSetting(Constants.ACCOUNT_SETTINGS.RECOMMENDED_VIDEOS, 'SPaccount_notifications', option), /** * Notify about activity on your channel. - * * @param option - ON | OFF */ setChannelActivity: (option: boolean) => this.#setSetting(Constants.ACCOUNT_SETTINGS.CHANNEL_ACTIVITY, 'SPaccount_notifications', option), /** * Notify about replies to your comments. - * * @param option - ON | OFF */ setCommentReplies: (option: boolean) => this.#setSetting(Constants.ACCOUNT_SETTINGS.COMMENT_REPLIES, 'SPaccount_notifications', option), /** * Notify when others mention your channel. - * * @param option - ON | OFF */ setMentions: (option: boolean) => this.#setSetting(Constants.ACCOUNT_SETTINGS.USER_MENTION, 'SPaccount_notifications', option), /** * Notify when others share your content on their channels. - * * @param option - ON | OFF */ setSharedContent: (option: boolean) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SHARED_CONTENT, 'SPaccount_notifications', option) @@ -68,25 +64,26 @@ class AccountManager { privacy: { /** * If set to true, your subscriptions won't be visible to others. - * * @param option - ON | OFF */ setSubscriptionsPrivate: (option: boolean) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SUBSCRIPTIONS_PRIVACY, 'SPaccount_privacy', option), /** * If set to true, saved playlists won't appear on your channel. - * * @param option - ON | OFF */ setSavedPlaylistsPrivate: (option: boolean) => this.#setSetting(Constants.ACCOUNT_SETTINGS.PLAYLISTS_PRIVACY, 'SPaccount_privacy', option) } }; } + /** * Internal method to perform changes on an account's settings. */ async #setSetting(setting_id: string, type: string, new_value: boolean) { throwIfMissing({ setting_id, type, new_value }); + const response = await this.#actions.browse(type); + const contents = (() => { switch (type.trim()) { case 'SPaccount_notifications': @@ -98,34 +95,43 @@ class AccountManager { throw new TypeError('undefined is not a function'); } })(); + const option = contents.find((option: any) => option.settingsSwitchRenderer.enableServiceEndpoint.setSettingEndpoint.settingItemIdForClient == setting_id); const setting_item_id = option.settingsSwitchRenderer.enableServiceEndpoint.setSettingEndpoint.settingItemId; + const set_setting = await this.#actions.account('account/set_setting', { new_value: type == 'SPaccount_privacy' ? !new_value : new_value, setting_item_id }); + return set_setting; } + /** * Retrieves channel info. */ async getInfo() { const response = await this.#actions.account('account/accounts_list', { client: 'ANDROID' }); + const account_item_section_renderer = findNode(response.data, 'contents', 'accountItem', 8, false); const profile = account_item_section_renderer.accountItem.serviceEndpoint.signInEndpoint.directSigninUserProfile; + const name = profile.accountName; const email = profile.email; const photo = profile.accountPhoto.thumbnails; const subscriber_count = account_item_section_renderer.accountItem.accountByline.runs.map((run: any) => run.text).join(''); const channel_id = response.data.contents[0].accountSectionListRenderer.footers[0].accountChannelRenderer.navigationEndpoint.browseEndpoint.browseId; + return { name, email, channel_id, subscriber_count, photo }; } + /** * Retrieves time watched statistics. */ async getTimeWatched() { const response = await this.#actions.browse('SPtime_watched', { client: 'ANDROID' }); const rows: any[] = findNode(response.data, 'contents', 'statRowRenderer', 11, false); + const stats = rows.map((row: any) => { const renderer = row.statRowRenderer; if (renderer) { @@ -135,17 +141,21 @@ class AccountManager { }; } }).filter((stat: any) => stat); + return stats; } + /** * Retrieves basic channel analytics. - * */ async getAnalytics() { const info = await this.getInfo(); + const params = Proto.encodeChannelAnalyticsParams(info.channel_id); const response = await this.#actions.browse('FEanalytics_screen', { params, client: 'ANDROID' }); + return new Analytics(response.data); } } -export default AccountManager; + +export default AccountManager; \ No newline at end of file diff --git a/src/core/Actions.ts b/src/core/Actions.ts index 0a804586..975e8b76 100644 --- a/src/core/Actions.ts +++ b/src/core/Actions.ts @@ -1,59 +1,61 @@ import Proto from '../proto/index'; -import { hasKeys, InnertubeError, MissingParamError, uuidv4 } from '../utils/Utils'; -import Constants from '../utils/Constants'; -import Parser, { ParsedResponse } from '../parser/index'; import Session from './Session'; +import Parser, { ParsedResponse } from '../parser/index'; + +import { hasKeys, InnertubeError, MissingParamError, uuidv4 } from '../utils/Utils'; export interface BrowseArgs { - params?: string; - is_ytm?: boolean; - is_ctoken?: boolean; - client?: string; + params?: string; + is_ytm?: boolean; + is_ctoken?: boolean; + client?: string; } export interface EngageArgs { - video_id?: string; - channel_id?: string; - comment_id?: string; - comment_action?: string; - params?: string; - text?: string; - target_language?: string; + video_id?: string; + channel_id?: string; + comment_id?: string; + comment_action?: string; + params?: string; + text?: string; + target_language?: string; } export interface AccountArgs { - new_value?: string | boolean; // TODO: is this correct? - setting_item_id?: string; - client?: string; + new_value?: string | boolean; // TODO: is this correct? + setting_item_id?: string; + client?: string; } export interface SearchArgs { - query?: string, - options?: { - period?: string, - duration?: string, - order?: string - }, - client?: string, - ctoken?: string, - params?: string - filters?: any // TODO: what is this type?? + query?: string, + options?: { + period?: string, + duration?: string, + order?: string + }, + client?: string, + ctoken?: string, + params?: string + filters?: any // TODO: what is this type?? } export interface AxioslikeResponse { - success: boolean; - status_code: number; - data: any; + success: boolean; + status_code: number; + data: any; } export type ActionsResponse = Promise; class Actions { #session; + constructor(session: Session) { this.#session = session; } + get session() { return this.#session; } @@ -68,6 +70,7 @@ class Actions { data: await response.json() }; } + /** * Covers `/browse` endpoint, mostly used to access * YouTube's sections such as the home feed, etc @@ -79,17 +82,22 @@ class Actions { async browse(id: string, args: BrowseArgs = {}) { if (this.#needsLogin(id) && !this.#session.logged_in) throw new InnertubeError('You are not signed in'); + const data: Record = {}; + if (args.params) data.params = args.params; + if (args.is_ctoken) { data.continuation = id; } else { data.browseId = id; } + if (args.client) { data.client = args.client; } + const response = await this.#session.http.fetch('/browse', { method: 'POST', body: JSON.stringify(data), @@ -97,8 +105,10 @@ class Actions { 'Content-Type': 'application/json' } }); + return this.#wrap(response); } + /** * Covers endpoints used to perform direct interactions * on YouTube. @@ -106,15 +116,19 @@ class Actions { async engage(action: string, args: EngageArgs = {}) { if (!this.#session.logged_in && !args.hasOwnProperty('text')) throw new InnertubeError('You are not signed in'); + const data: Record = {}; + switch (action) { case 'like/like': case 'like/dislike': case 'like/removelike': if (!hasKeys(args, 'video_id')) throw new MissingParamError('Arguments lacks video_id'); + data.target = {}; data.target.videoId = args.video_id; + if (args.params) { data.params = args.params; } @@ -123,18 +137,22 @@ class Actions { case 'subscription/unsubscribe': if (!hasKeys(args, 'channel_id')) throw new MissingParamError('Arguments lacks channel_id'); + data.channelIds = [ args.channel_id ]; data.params = action === 'subscription/subscribe' ? 'EgIIAhgA' : 'CgIIAhgA'; break; case 'comment/create_comment': data.commentText = args.text; + if (!hasKeys(args, 'video_id')) throw new MissingParamError('Arguments lacks video_id'); + data.createCommentParams = Proto.encodeCommentParams(args.video_id); break; case 'comment/create_comment_reply': if (!hasKeys(args, 'comment_id', 'video_id', 'text')) throw new MissingParamError('Arguments lacks comment_id, video_id or text'); + data.createReplyParams = Proto.encodeCommentReplyParams(args.comment_id, args.video_id); data.commentText = args.text; break; @@ -156,6 +174,7 @@ class Actions { default: throw new InnertubeError('Action not implemented', action); } + const response = await this.#session.http.fetch(`/${action}`, { method: 'POST', body: JSON.stringify(data), @@ -163,17 +182,19 @@ class Actions { 'Content-Type': 'application/json' } }); + return this.#wrap(response); } + /** * Covers endpoints related to account management. */ async account(action: string, args: AccountArgs = {}) { if (!this.#session.logged_in) throw new InnertubeError('You are not signed in'); - const data: Record = { - client: args.client - }; + + const data: Record = { client: args.client }; + switch (action) { case 'account/set_setting': data.newValue = { @@ -186,6 +207,7 @@ class Actions { default: throw new InnertubeError('Action not implemented', action); } + const response = await this.#session.http.fetch(`/${action}`, { method: 'POST', body: JSON.stringify(data), @@ -193,22 +215,28 @@ class Actions { 'Content-Type': 'application/json' } }); + return this.#wrap(response); } + /** * Endpoint used for search. */ async search(args: SearchArgs = {}) { const data: Record = { client: args.client }; + if (args.query) { data.query = args.query; } + if (args.ctoken) { data.continuation = args.ctoken; } + if (args.params) { data.params = args.params; } + if (args.filters) { if (args.client == 'YTMUSIC') { data.params = Proto.encodeMusicSearchFilters(args.filters); @@ -216,6 +244,7 @@ class Actions { data.params = Proto.encodeSearchFilters(args.filters); } } + const response = await this.#session.http.fetch('/search', { method: 'POST', body: JSON.stringify(data), @@ -223,18 +252,19 @@ class Actions { 'Content-Type': 'application/json' } }); + return this.#wrap(response); } + /** * Endpoint used fo Shorts' sound search. */ - async searchSound(args: { - query: string; - }) { + async searchSound(args: { query: string; }) { const data = { query: args.query, client: 'ANDROID' }; + const response = await this.#session.http.fetch('/sfv/search', { method: 'POST', body: JSON.stringify(data), @@ -242,22 +272,19 @@ class Actions { 'Content-Type': 'application/json' } }); + return this.#wrap(response); } + /** * Channel management endpoints. - * */ - async channel(action: string, args: { - new_name?: string; - new_description?: string; - client?: string; - } = {}) { + async channel(action: string, args: { new_name?: string; new_description?: string; client?: string; } = {}) { if (!this.#session.logged_in) throw new InnertubeError('You are not signed in'); - const data: Record = { - client: args.client || 'ANDROID' - }; + + const data: Record = { client: args.client || 'ANDROID' }; + switch (action) { case 'channel/edit_name': data.givenName = args.new_name; @@ -270,6 +297,7 @@ class Actions { default: throw new InnertubeError('Action not implemented', action); } + const response = await this.#session.http.fetch(`/${action}`, { method: 'POST', body: JSON.stringify(data), @@ -277,21 +305,24 @@ class Actions { 'Content-Type': 'application/json' } }); + return this.#wrap(response); } + /** * Covers endpoints used for playlist management. - * */ async playlist(action: string, args: { - title?: string; - ids?: string[]; // TODO: this was a string before, but I made it an array, is this correct? - playlist_id?: string; - action?: string; - } = {}) { + title?: string; + ids?: string[]; + playlist_id?: string; + action?: string; + } = {}) { if (!this.#session.logged_in) throw new InnertubeError('You are not signed in'); + const data: Record = {}; + switch (action) { case 'playlist/create': data.title = args.title; @@ -324,6 +355,7 @@ class Actions { default: throw new InnertubeError('Action not implemented', action); } + const response = await this.#session.http.fetch(`/${action}`, { method: 'POST', body: JSON.stringify(data), @@ -331,20 +363,24 @@ class Actions { 'Content-Type': 'application/json' } }); + return this.#wrap(response); } + /** * Covers endpoints used for notifications management. */ async notifications(action: string, args: { - pref?: string; - channel_id?: string; - ctoken?: string; - params?: string - } = {}) { + pref?: string; + channel_id?: string; + ctoken?: string; + params?: string + } = {}) { if (!this.#session.logged_in) throw new InnertubeError('You are not signed in'); + const data: Record = {}; + switch (action) { case 'modify_channel_preference': if (!hasKeys(args, 'channel_id', 'pref')) @@ -371,6 +407,7 @@ class Actions { default: throw new InnertubeError('Action not implemented', action); } + const response = await this.#session.http.fetch(`/notification/${action}`, { method: 'POST', body: JSON.stringify(data), @@ -378,21 +415,24 @@ class Actions { 'Content-Type': 'application/json' } }); + return this.#wrap(response); } + /** * Covers livechat endpoints. */ async livechat(action: string, args: { - text?: string; - video_id?: string; - channel_id?: string; - ctoken?: string; - params?: string; - client?: string; - } = {}) { + text?: string; + video_id?: string; + channel_id?: string; + ctoken?: string; + params?: string; + client?: string; + } = {}) { // TODO: should client be required? const data: Record = { client: args.client }; + switch (action) { case 'live_chat/get_live_chat': case 'live_chat/get_live_chat_replay': @@ -424,6 +464,7 @@ class Actions { default: throw new InnertubeError('Action not implemented', action); } + const response = await this.#session.http.fetch(`/${action}`, { method: 'POST', body: JSON.stringify(data), @@ -431,18 +472,19 @@ class Actions { 'Content-Type': 'application/json' } }); + return this.#wrap(response); } + /** * Endpoint used to retrieve video thumbnails. */ - async thumbnails(args: { - video_id: string; - }) { + async thumbnails(args: { video_id: string; }) { const data = { client: 'ANDROID', videoId: args.video_id }; + const response = await this.#session.http.fetch('/thumbnails', { method: 'POST', body: JSON.stringify(data), @@ -450,8 +492,10 @@ class Actions { 'Content-Type': 'application/json' } }); + return this.#wrap(response); } + /** * Place Autocomplete endpoint, found it in the APK but * doesn't seem to be used anywhere on YouTube (maybe for ads?). @@ -462,15 +506,15 @@ class Actions { * console.info(places.data); * ``` */ - async geo(action: string, args: { - input: string; - }) { + async geo(action: string, args: { input: string; }) { if (!this.#session.logged_in) throw new InnertubeError('You are not signed in'); + const data = { input: args.input, client: 'ANDROID' }; + const response = await this.#session.http.fetch(`/geo/${action}`, { method: 'POST', body: JSON.stringify(data), @@ -478,18 +522,19 @@ class Actions { 'Content-Type': 'application/json' } }); + return this.#wrap(response); } + /** * Covers endpoints used to report content. */ - async flag(action: string, args: { - action: string; - params?: string; - }) { + async flag(action: string, args: { action: string; params?: string; }) { if (!this.#session.logged_in) throw new InnertubeError('You are not signed in'); + const data: Record = {}; + switch (action) { case 'flag/flag': data.action = args.action; @@ -500,6 +545,7 @@ class Actions { default: throw new InnertubeError('Action not implemented', action); } + const response = await this.#session.http.fetch(`/${action}`, { method: 'POST', body: JSON.stringify(data), @@ -507,18 +553,19 @@ class Actions { 'Content-Type': 'application/json' } }); + return this.#wrap(response); } + /** * Covers specific YouTube Music endpoints. */ - async music(action: string, args: { - input?: string; - }) { + async music(action: string, args: { input?: string; }) { const data = { input: args.input || '', client: 'YTMUSIC' }; + const response = await this.#session.http.fetch(`/music/${action}`, { method: 'POST', body: JSON.stringify(data), @@ -526,23 +573,24 @@ class Actions { 'Content-Type': 'application/json' } }); + return this.#wrap(response); } + /** * Mostly used for pagination and specific operations. */ - async next(args: { - video_id?: string; - ctoken?: string; - client?: string; - } = {}) { + async next(args: { video_id?: string; ctoken?: string; client?: string; } = {}) { const data: Record = { client: args.client }; + if (args.ctoken) { data.continuation = args.ctoken; } + if (args.video_id) { data.videoId = args.video_id; } + const response = await this.#session.http.fetch('/next', { method: 'POST', body: JSON.stringify(data), @@ -550,8 +598,10 @@ class Actions { 'Content-Type': 'application/json' } }); + return this.#wrap(response); } + /** * Used to retrieve video info. */ @@ -575,12 +625,15 @@ class Actions { }, videoId: id }; + if (client) { data.client = client; } + if (cpn) { data.cpn = cpn; } + const response = await this.#session.http.fetch('/player', { method: 'POST', body: JSON.stringify(data), @@ -588,49 +641,22 @@ class Actions { 'Content-Type': 'application/json' } }); + return this.#wrap(response); } - /** - * Covers search suggestion endpoints. - */ - async getSearchSuggestions(client: 'YOUTUBE' | 'YTMUSIC', query: string) { - if (![ 'YOUTUBE', 'YTMUSIC' ].includes(client)) - throw new InnertubeError('Invalid client', client); - const response = await ({ - YOUTUBE: async () => { - const params = new URLSearchParams({ - q: query, - ds: 'yt', - client: 'youtube', - xssi: 't', - oe: 'UTF', - gl: this.#session.context.client.gl, - hl: this.#session.context.client.hl - }); - const response = await this.#session.http.fetch(`search?${params.toString()}`, { - baseURL: Constants.URLS.YT_SUGGESTIONS, - method: 'GET' - }); - return this.#wrap(response); - }, - YTMUSIC: () => this.music('get_search_suggestions', { - input: query - }) - }[client])(); - return response; - } + /** * Endpoint used to retrieve user mention suggestions. */ - async getUserMentionSuggestions(args: { - input: string; - }) { + async getUserMentionSuggestions(args: { input: string; }) { if (!this.#session.logged_in) throw new InnertubeError('You are not signed in'); + const data = { input: args.input, client: 'ANDROID' }; + const response = await this.#session.http.fetch('/get_user_mention_suggestions', { method: 'POST', body: JSON.stringify(data), @@ -638,40 +664,39 @@ class Actions { 'Content-Type': 'application/json' } }); + return this.#wrap(response); } + /** * Executes an API call. * @param action - endpoint * @param args - call arguments */ - async execute(action: string, args: { - [key: string]: any; - parse: true; - }) : Promise; - async execute(action: string, args: { - [key: string]: any; - parse?: false; - }) : Promise; - async execute(action: string, args: { - [key: string]: any; - parse?: boolean; - }): Promise { + async execute(action: string, args: { [key: string]: any; parse: true; }) : Promise; + async execute(action: string, args: { [key: string]: any; parse?: false; }) : Promise; + async execute(action: string, args: { [key: string]: any; parse?: boolean; }): Promise { const data = { ...args }; + if (Reflect.has(data, 'parse')) delete data.parse; + if (Reflect.has(data, 'request')) delete data.request; + if (Reflect.has(data, 'clientActions')) delete data.clientActions; + if (Reflect.has(data, 'action')) { data.actions = [ data.action ]; delete data.action; } + if (Reflect.has(data, 'token')) { data.continuation = data.token; delete data.token; } + const response = await this.#session.http.fetch(action, { method: 'POST', body: JSON.stringify(data), @@ -679,11 +704,14 @@ class Actions { 'Content-Type': 'application/json' } }); + if (args.parse) { return Parser.parseResponse(await response.json()); } + return this.#wrap(response); } + #needsLogin(id: string) { return [ 'FElibrary', @@ -695,5 +723,6 @@ class Actions { ].includes(id); } } + // TODO: maybe do this inferrance in a more elegant way -export default Actions; +export default Actions; \ No newline at end of file diff --git a/src/core/Feed.ts b/src/core/Feed.ts index 79de94bd..b4791b85 100644 --- a/src/core/Feed.ts +++ b/src/core/Feed.ts @@ -29,12 +29,14 @@ class Feed { #continuation?: ObservedArray; #actions; #memo; + constructor(actions: Actions, data: any, already_parsed = false) { if (data.on_response_received_actions || data.on_response_received_endpoints || already_parsed) { this.#page = data; } else { this.#page = Parser.parseResponse(data); } + const memo = this.#page.on_response_received_commands ? this.#page.on_response_received_commands_memo : @@ -44,11 +46,14 @@ class Feed { this.#page.contents_memo : this.#page.on_response_received_actions ? this.#page.on_response_received_actions_memo : undefined; + if (!memo) throw new InnertubeError('No memo found in feed'); + this.#memo = memo; this.#actions = actions; } + /** * Get all videos on a given page via memo */ @@ -62,39 +67,46 @@ class Feed { WatchCardCompactVideo ]); } + /** * Get all playlists on a given page via memo */ static getPlaylistsFromMemo(memo: Memo) { return memo.getType([ Playlist, GridPlaylist ]); } + /** * Get all the videos in the feed */ get videos() { return Feed.getVideosFromMemo(this.#memo); } + /** * Get all the community posts in the feed */ get posts() { return this.#memo.getType([ BackstagePost, Post ]); } + /** * Get all the channels in the feed */ get channels() { return this.#memo.getType([ Channel, GridChannel ]); } + /** * Get all playlists in the feed */ get playlists() { return Feed.getPlaylistsFromMemo(this.#memo); } + get memo() { return this.#memo; } + /** * Returns contents from the page. */ @@ -102,48 +114,57 @@ class Feed { const tab_content = this.#memo.getType(Tab)?.[0]?.content.item(); const reload_continuation_items = this.#memo.getType(ReloadContinuationItemsCommand)?.[0]; const append_continuation_items = this.#memo.getType(AppendContinuationItemsAction)?.[0]; + return tab_content || reload_continuation_items || append_continuation_items; } + /** * Returns all segments/sections from the page. */ get shelves() { return this.#memo.getType([ Shelf, RichShelf, ReelShelf ]); } + /** * Finds shelf by title. - * */ getShelf(title: string) { return this.shelves.find((shelf) => shelf.title.toString() === title); } + /** * Returns secondary contents from the page. */ get secondary_contents() { if (!this.#page.contents.is_node) return undefined; + const node = this.#page.contents.item(); + if (!node.is(TwoColumnBrowseResults, TwoColumnSearchResults)) return undefined; + return node.secondary_contents; } + get actions() { return this.#actions; } + /** * Get the original page data */ get page() { return this.#page; } + /** * Checks if the feed has continuation. - * */ get has_continuation() { return (this.#memo.get('ContinuationItem') || []).length > 0; } + /** * Retrieves continuation data as it is. */ @@ -153,21 +174,25 @@ class Feed { throw new InnertubeError('There are too many continuations, you\'ll need to find the correct one yourself in this.page'); if (this.#continuation.length === 0) throw new InnertubeError('There are no continuations'); + const response = await this.#continuation[0].endpoint.call(this.#actions, undefined, true); + return response; } + this.#continuation = this.#memo.getType(ContinuationItem); + if (this.#continuation) return this.getContinuationData(); - } + /** * Retrieves next batch of contents and returns a new {@link Feed} object. - * */ async getContinuation() { const continuation_data = await this.getContinuationData(); return new Feed(this.actions, continuation_data, true); } } -export default Feed; + +export default Feed; \ No newline at end of file diff --git a/src/core/FilterableFeed.ts b/src/core/FilterableFeed.ts index 26c2637d..a9e0de97 100644 --- a/src/core/FilterableFeed.ts +++ b/src/core/FilterableFeed.ts @@ -7,31 +7,39 @@ import Feed from './Feed'; class FilterableFeed extends Feed { #chips?: ObservedArray; + constructor(actions: Actions, data: any, already_parsed = false) { super(actions, data, already_parsed); } + /** * Get filters for the feed - * */ get filter_chips() { if (this.#chips) return this.#chips || []; + if (this.memo.getType(FeedFilterChipBar)?.length > 1) throw new InnertubeError('There are too many feed filter chipbars, you\'ll need to find the correct one yourself in this.page'); + if (this.memo.getType(FeedFilterChipBar)?.length === 0) throw new InnertubeError('There are no feed filter chipbars'); + this.#chips = this.memo.getType(ChipCloudChip); + return this.#chips || []; } + get filters() { return this.filter_chips.map((chip) => chip.text.toString()) || []; } + /** * Applies given filter and returns a new {@link Feed} object. */ async getFilteredFeed(filter: string | ChipCloudChip) { let target_filter: ChipCloudChip | undefined; + if (typeof filter === 'string') { if (!this.filters.includes(filter)) throw new InnertubeError('Filter not found', { @@ -43,12 +51,15 @@ class FilterableFeed extends Feed { } else { throw new InnertubeError('Invalid filter'); } + if (!target_filter) throw new InnertubeError('Filter not found'); if (target_filter.is_selected) return this; + const response = await target_filter.endpoint?.call(this.actions, undefined, true); return new Feed(this.actions, response, true); } } -export default FilterableFeed; + +export default FilterableFeed; \ No newline at end of file diff --git a/src/core/InteractionManager.ts b/src/core/InteractionManager.ts index 249d9bc3..91bb9714 100644 --- a/src/core/InteractionManager.ts +++ b/src/core/InteractionManager.ts @@ -3,9 +3,11 @@ import Actions from './Actions'; class InteractionManager { #actions; + constructor(actions: Actions) { this.#actions = actions; } + /** * Likes a given video. */ @@ -14,6 +16,7 @@ class InteractionManager { const action = await this.#actions.engage('like/like', { video_id }); return action; } + /** * Dislikes a given video. */ @@ -22,6 +25,7 @@ class InteractionManager { const action = await this.#actions.engage('like/dislike', { video_id }); return action; } + /** * Removes a like/dislike. */ @@ -30,6 +34,7 @@ class InteractionManager { const action = await this.#actions.engage('like/removelike', { video_id }); return action; } + /** * Subscribes to a given channel. */ @@ -38,6 +43,7 @@ class InteractionManager { const action = await this.#actions.engage('subscription/subscribe', { channel_id }); return action; } + /** * Unsubscribes from a given channel. */ @@ -46,6 +52,7 @@ class InteractionManager { const action = await this.#actions.engage('subscription/unsubscribe', { channel_id }); return action; } + /** * Posts a comment on a given video. */ @@ -54,17 +61,16 @@ class InteractionManager { const action = await this.#actions.engage('comment/create_comment', { video_id, text }); return action; } + /** * Translates a given text using YouTube's comment translate feature. * * @param target_language - an ISO language code * @param args - optional arguments */ - async translate(text: string, target_language: string, args: { - video_id?: string; - comment_id?: string; - } = {}) { + async translate(text: string, target_language: string, args: { video_id?: string; comment_id?: string; } = {}) { throwIfMissing({ text, target_language }); + const response = await await this.#actions.engage('comment/perform_comment_action', { video_id: args.video_id, comment_id: args.comment_id, @@ -72,7 +78,9 @@ class InteractionManager { comment_action: 'translate', text }); + const translated_content = findNode(response.data, 'frameworkUpdates', 'content', 7, false); + return { success: response.success, status_code: response.status_code, @@ -80,6 +88,7 @@ class InteractionManager { data: response.data }; } + /** * Changes notification preferences for a given channel. * Only works with channels you are subscribed to. @@ -90,4 +99,5 @@ class InteractionManager { return action; } } -export default InteractionManager; + +export default InteractionManager; \ No newline at end of file diff --git a/src/core/Music.ts b/src/core/Music.ts index 263e4838..9ce10d7e 100644 --- a/src/core/Music.ts +++ b/src/core/Music.ts @@ -1,126 +1,148 @@ -import Parser from '../parser/index'; -import { observe, YTNode } from '../parser/helpers'; +import Session from './Session'; import Search from '../parser/ytmusic/Search'; import HomeFeed from '../parser/ytmusic/HomeFeed'; import Explore from '../parser/ytmusic/Explore'; import Library from '../parser/ytmusic/Library'; import Artist from '../parser/ytmusic/Artist'; import Album from '../parser/ytmusic/Album'; -import { InnertubeError, throwIfMissing } from '../utils/Utils'; -import Session from './Session'; + +import Parser from '../parser/index'; +import { observe, YTNode } from '../parser/helpers'; + +import Tab from '../parser/classes/Tab'; import SingleColumnBrowseResults from '../parser/classes/SingleColumnBrowseResults'; import TabbedSearchResults from '../parser/classes/TabbedSearchResults'; import TwoColumnBrowseResults from '../parser/classes/TwoColumnBrowseResults'; -import Tab from '../parser/classes/Tab'; import SearchSuggestionsSection from '../parser/classes/SearchSuggestionsSection'; import NavigationEndpoint from '../parser/classes/NavigationEndpoint'; import MusicDescriptionShelf from '../parser/classes/MusicDescriptionShelf'; import MusicCarouselShelf from '../parser/classes/MusicCarouselShelf'; +import { InnertubeError, throwIfMissing } from '../utils/Utils'; + class Music { #actions; + constructor(session: Session) { this.#actions = session.actions; } + /** * Searches on YouTube Music. */ async search(query: string, filters: { - type?: 'all' | 'song' | 'video' | 'album' | 'playlist' | 'artist'; - } = {}) { + type?: 'all' | 'song' | 'video' | 'album' | 'playlist' | 'artist'; + } = {}) { throwIfMissing({ query }); const response = await this.#actions.search({ query, filters, client: 'YTMUSIC' }); - return new Search(response, this.#actions, { - is_filtered: Reflect.has(filters, 'type') && filters.type !== 'all' - }); + return new Search(response, this.#actions, { is_filtered: Reflect.has(filters, 'type') && filters.type !== 'all' }); } + /** * Retrieves the home feed. - * */ async getHomeFeed() { const response = await this.#actions.browse('FEmusic_home', { client: 'YTMUSIC' }); return new HomeFeed(response, this.#actions); } + /** * Retrieves the Explore feed. - * */ async getExplore() { const response = await this.#actions.browse('FEmusic_explore', { client: 'YTMUSIC' }); return new Explore(response); // TODO: return new Explore(response, this.#actions); } + /** * Retrieves the Library. - * */ async getLibrary() { const response = await this.#actions.browse('FEmusic_liked_albums', { client: 'YTMUSIC' }); return new Library(response); // TODO: return new Library(response, this.#actions); } + /** * Retrieves artist's info & content. - * */ async getArtist(artist_id: string) { throwIfMissing({ artist_id }); + if (!artist_id.startsWith('UC')) throw new InnertubeError('Invalid artist id', artist_id); + const response = await this.#actions.browse(artist_id, { client: 'YTMUSIC' }); return new Artist(response, this.#actions); } + /** * Retrieves album. - * */ async getAlbum(album_id: string) { throwIfMissing({ album_id }); + if (!album_id.startsWith('MPR')) throw new InnertubeError('Invalid album id', album_id); + const response = await this.#actions.browse(album_id, { client: 'YTMUSIC' }); return new Album(response, this.#actions); } + /** * Retrieves song lyrics. - * */ async getLyrics(video_id: string) { throwIfMissing({ video_id }); + const response = await this.#actions.next({ video_id, client: 'YTMUSIC' }); + const data = Parser.parseResponse(response.data); const node = data.contents.item(); + if (!node.is(SingleColumnBrowseResults, TabbedSearchResults, TwoColumnBrowseResults)) throw new InnertubeError('Invalid id', video_id); + const tab = node.tabs.array().get({ title: 'Lyrics' }); const page = await tab?.key('endpoint').nodeOfType(NavigationEndpoint).call(this.#actions, 'YTMUSIC', true); + if (!page) throw new InnertubeError('Invalid video id'); + if (page.contents.constructor.name === 'Message') throw new InnertubeError(page.contents.item().key('text').any(), video_id); + const description_shelf = page.contents.item().key('contents').parsed().array().get({ type: 'MusicDescriptionShelf' })?.as(MusicDescriptionShelf); + return { text: description_shelf?.description.toString(), footer: description_shelf?.footer }; } + /** * Retrieves up next. - * */ async getUpNext(video_id: string) { throwIfMissing({ video_id }); + const response = await this.#actions.next({ video_id, client: 'YTMUSIC' }); + const data = Parser.parseResponse(response.data); const node = data.contents.item(); + if (!node.is(SingleColumnBrowseResults, TabbedSearchResults, TwoColumnBrowseResults)) throw new InnertubeError('Invalid id', video_id); + const tab = node.tabs.array().get({ title: 'Up next' }); + // TODO: verify this is a Tab const upnext_content = tab?.as(Tab).content.item().key('content').any(); + if (!upnext_content) throw new InnertubeError('Invalid id', video_id); + return { id: upnext_content.playlist_id, title: upnext_content.title, @@ -128,28 +150,36 @@ class Music { contents: observe(upnext_content.contents) }; } + /** * Retrieves related content. - * */ async getRelated(video_id: string) { throwIfMissing({ video_id }); + const response = await this.#actions.next({ video_id, client: 'YTMUSIC' }); + const data = Parser.parseResponse(response.data); const node = data.contents.item(); + if (!node.is(SingleColumnBrowseResults, TabbedSearchResults, TwoColumnBrowseResults)) throw new InnertubeError('Invalid id', video_id); + const tab = node.tabs.array().get({ title: 'Related' }); const page = await tab?.key('endpoint').nodeOfType(NavigationEndpoint).call(this.#actions, 'YTMUSIC', true); + if (!page) throw new InnertubeError('Invalid video id'); + const shelves = page.contents.item().key('contents').parsed().array().filterType(MusicCarouselShelf); const info = page.contents.item().key('contents').parsed().array().get({ type: 'MusicDescriptionShelf' })?.as(MusicDescriptionShelf); + return { sections: shelves, info: info?.description.toString() || '' }; } + /** * Retrieves search suggestions for the given query. */ @@ -159,10 +189,14 @@ class Music { input: query, client: 'YTMUSIC' }); + const search_suggestions_section = response.contents_memo.getType(SearchSuggestionsSection)?.[0]; + if (!search_suggestions_section.contents.is_array) return observe([] as YTNode[]); + return search_suggestions_section?.contents.array(); } } -export default Music; + +export default Music; \ No newline at end of file diff --git a/src/core/OAuth.ts b/src/core/OAuth.ts index ac319791..c5460d07 100644 --- a/src/core/OAuth.ts +++ b/src/core/OAuth.ts @@ -3,18 +3,18 @@ import { OAuthError, uuidv4 } from '../utils/Utils'; import Session from './Session'; export interface Credentials { - /** - * Token used to sign in. - */ - access_token: string; - /** - * Token used to get a new access token. - */ - refresh_token: string; - /** - * Access token's expiration date, which is usually 24hrs-ish. - */ - expires: Date; + /** + * Token used to sign in. + */ + access_token: string; + /** + * Token used to get a new access token. + */ + refresh_token: string; + /** + * Access token's expiration date, which is usually 24hrs-ish. + */ + expires: Date; } // TODO: actual type info for this. @@ -24,6 +24,7 @@ export type OAuthAuthEventHandler = (data: { credentials: Credentials; status: 'SUCCESS'; }) => any; + export type OAuthAuthPendingEventHandler = (data: OAuthAuthPendingData) => any; export type OAuthAuthErrorEventHandler = (err: OAuthError) => any; @@ -32,9 +33,11 @@ class OAuth { #session: Session; #credentials?: Credentials; #polling_interval = 5; + constructor(session: Session) { this.#session = session; } + /** * Starts the auth flow in case no valid credentials are available. */ @@ -44,17 +47,20 @@ class OAuth { await this.#getUserCode(); } } + /** * Asks the server for a user code and verification URL. */ async #getUserCode() { this.#identity = await this.#getClientIdentity(); + const data = { client_id: this.#identity.client_id, scope: Constants.OAUTH.SCOPE, device_id: uuidv4(), model_name: Constants.OAUTH.MODEL_NAME }; + const response = await this.#session.http.fetch_function(new URL('/o/oauth2/device/code', Constants.URLS.YT_BASE), { body: JSON.stringify(data), method: 'POST', @@ -62,11 +68,14 @@ class OAuth { 'Content-Type': 'application/json' } }); + const response_data = await response.json(); + this.#session.emit('auth-pending', response_data); this.#polling_interval = response_data.interval; this.#startPolling(response_data.device_code); } + /** * Polls the authorization server until access is granted by the user. */ @@ -77,6 +86,7 @@ class OAuth { code: device_code, grant_type: Constants.OAUTH.GRANT_TYPE }; + try { const response = await this.#session.http.fetch_function(new URL('/o/oauth2/token', Constants.URLS.YT_BASE), { body: JSON.stringify(data), @@ -85,7 +95,9 @@ class OAuth { 'Content-Type': 'application/json' } }); + const response_data = await response.json(); + if (response_data.error) { switch (response_data.error) { case 'access_denied': @@ -101,16 +113,20 @@ class OAuth { } return; } + const expiration_date = new Date(new Date().getTime() + response_data.expires_in * 1000); + this.#credentials = { access_token: response_data.access_token, refresh_token: response_data.refresh_token, expires: expiration_date }; + this.#session.emit('auth', { credentials: this.#credentials, status: 'SUCCESS' }); + clearInterval(poller); } catch (err) { clearInterval(poller); @@ -118,6 +134,7 @@ class OAuth { } }, this.#polling_interval * 1000); } + /** * Refreshes the access token if necessary. */ @@ -127,17 +144,21 @@ class OAuth { await this.#refreshAccessToken(); } } + /** * Retrieves a new access token using the refresh token. */ async #refreshAccessToken() { if (!this.#credentials) return; + this.#identity = await this.#getClientIdentity(); + const data = { ...this.#identity, refresh_token: this.#credentials.refresh_token, grant_type: 'refresh_token' }; + const response = await this.#session.http.fetch_function(new URL('/o/oauth2/token', Constants.URLS.YT_BASE), { body: JSON.stringify(data), method: 'POST', @@ -145,23 +166,28 @@ class OAuth { 'Content-Type': 'application/json' } }); + if (response instanceof Error) { const error = new OAuthError('Could not refresh access token.', { status: 'FAILED' }); this.#session.emit('update-credentials', error); throw error; } + const response_data = await response.json(); const expiration_date = new Date(new Date().getTime() + response_data.expires_in * 1000); + this.#credentials = { access_token: response_data.access_token, refresh_token: response_data.refresh_token || this.#credentials.refresh_token, expires: expiration_date }; + this.#session.emit('update-credentials', { credentials: this.#credentials, status: 'SUCCESS' }); } + /** * Revokes credentials. */ @@ -171,37 +197,43 @@ class OAuth { method: 'post' }); } + /** * Retrieves client identity from YouTube TV. */ async #getClientIdentity() { - const response = await this.#session.http.fetch_function(new URL('/tv', Constants.URLS.YT_BASE), { - headers: Constants.OAUTH.HEADERS - }); + const response = await this.#session.http.fetch_function(new URL('/tv', Constants.URLS.YT_BASE), { headers: Constants.OAUTH.HEADERS }); + const response_data = await response.text(); const url_body = Constants.OAUTH.REGEX.AUTH_SCRIPT.exec(response_data)?.[1]; + if (!url_body) throw new OAuthError('Could not obtain script url.', { status: 'FAILED' }); - const script = await this.#session.http.fetch(url_body, { - baseURL: Constants.URLS.YT_BASE - }); + + const script = await this.#session.http.fetch(url_body, { baseURL: Constants.URLS.YT_BASE }); + const client_identity = (await script.text()) .replace(/\n/g, '') .match(Constants.OAUTH.REGEX.CLIENT_IDENTITY); + // TODO: check this. const groups = client_identity?.groups; + if (!groups) throw new OAuthError('Could not obtain client identity.', { status: 'FAILED' }); + return groups; } + get credentials() { return this.#credentials; } + validateCredentials(): this is this & { credentials: Credentials } { return this.#credentials && - Reflect.has(this.#credentials, 'access_token') && - Reflect.has(this.#credentials, 'refresh_token') && - Reflect.has(this.#credentials, 'expires') || false; + Reflect.has(this.#credentials, 'access_token') && + Reflect.has(this.#credentials, 'refresh_token') && + Reflect.has(this.#credentials, 'expires') || false; } } export default OAuth; diff --git a/src/core/Player.ts b/src/core/Player.ts index 4c15730e..6aa79009 100644 --- a/src/core/Player.ts +++ b/src/core/Player.ts @@ -10,20 +10,26 @@ export default class Player { #signature; #signature_timestamp; #player_id; + constructor(signature: Signature, ntoken: NToken, signature_timestamp: number, player_id: string) { this.#ntoken = ntoken; this.#signature = signature; this.#signature_timestamp = signature_timestamp; this.#player_id = player_id; } + static async fromCache(cache: UniversalCache, player_id: string) { const buffer = await cache.get(player_id); + if (!buffer) return null; + const view = new DataView(buffer); const version = view.getUint32(0, true); + if (version !== Player.LIBRARY_VERSION) return null; + const sig_timestamp = view.getUint32(4, true); const sig_decipher_len = view.getUint32(8, true); const sig_decipher_buf = buffer.slice(12, 12 + sig_decipher_len); @@ -31,32 +37,42 @@ export default class Player { return new Player(Signature.fromArrayBuffer(sig_decipher_buf), NToken.fromArrayBuffer(ntoken_transform_buf), sig_timestamp, player_id); } + static async fromSource(cache: UniversalCache | undefined, sig_timestamp: number, sig_decipher_sc: string, ntoken_sc: string, player_id: string) { const player = new Player(Signature.fromSourceCode(sig_decipher_sc), NToken.fromSourceCode(ntoken_sc), sig_timestamp, player_id); await player.cache(cache); return player; } + async cache(cache?: UniversalCache) { - if (!cache) - return; + if (!cache) return; + const ntokenBuf = this.#ntoken.toArrayBuffer(); const sigDecipherBuf = this.#signature.toArrayBuffer(); const buffer = new ArrayBuffer(12 + sigDecipherBuf.byteLength + ntokenBuf.byteLength); const view = new DataView(buffer); + view.setUint32(0, Player.LIBRARY_VERSION, true); view.setUint32(4, this.#signature_timestamp, true); view.setUint32(8, sigDecipherBuf.byteLength, true); + new Uint8Array(buffer).set(new Uint8Array(sigDecipherBuf), 12); new Uint8Array(buffer).set(new Uint8Array(ntokenBuf), 12 + sigDecipherBuf.byteLength); + await cache.set(this.#player_id, new Uint8Array(buffer)); } + decipher(url?: string, signature_cipher?: string, cipher?: string) { url = url || signature_cipher || cipher; + if (!url) throw new PlayerError('No valid URL to decipher'); + const args = new URLSearchParams(url); const url_components = new URL(args.get('url') || url); + url_components.searchParams.set('ratebypass', 'yes'); + if (signature_cipher || cipher) { const signature = this.#signature.decipher(url); const sp = args.get('sp'); @@ -64,19 +80,25 @@ export default class Player { url_components.searchParams.set(sp, signature) : url_components.searchParams.set('signature', signature); } + const n = url_components.searchParams.get('n'); + if (n) { const ntoken = this.#ntoken.transform(n); url_components.searchParams.set('n', ntoken); } + return url_components.toString(); } + get url() { return new URL(`/s/player/${this.#player_id}/player_ias.vflset/en_US/base.js`, Constants.URLS.YT_BASE).toString(); } + get sts() { return this.#signature_timestamp; } + static async create(cache: UniversalCache | undefined, fetch: FetchFunction = globalThis.fetch) { const url = new URL('/iframe_api', Constants.URLS.YT_BASE); const res = await fetch(url); @@ -118,32 +140,40 @@ export default class Player { return await Player.fromSource(cache, sig_timestamp, sig_decipher_sc, ntoken_sc, player_id); } + /** * Extracts the signature timestamp from the player source code. */ static extractSigTimestamp(data: string) { return parseInt(getStringBetweenStrings(data, 'signatureTimestamp:', ',') || '0'); } + /** * Extracts the signature decipher algorithm. */ static extractSigDecipherSc(data: string) { const sig_alg_sc = getStringBetweenStrings(data, 'this.audioTracks};var', '};'); const sig_data = getStringBetweenStrings(data, 'function(a){a=a.split("")', 'return a.join("")}'); + if (!sig_alg_sc || !sig_data) throw new PlayerError('Failed to extract signature decipher algorithm'); + return sig_alg_sc + sig_data; } + /** * Extracts the n-token decipher algorithm. */ static extractNTokenSc(data: string) { const sc = `var b=a.split("")${getStringBetweenStrings(data, 'b=a.split("")', '}return b.join("")}')}} return b.join("");`; + if (!sc) throw new PlayerError('Failed to extract n-token decipher algorithm'); + return sc; } + static get LIBRARY_VERSION() { return 1; } -} +} \ No newline at end of file diff --git a/src/core/PlaylistManager.ts b/src/core/PlaylistManager.ts index 79bad326..e476d06a 100644 --- a/src/core/PlaylistManager.ts +++ b/src/core/PlaylistManager.ts @@ -1,19 +1,24 @@ import Playlist from '../parser/youtube/Playlist'; -import { InnertubeError, throwIfMissing } from '../utils/Utils'; import Actions from './Actions'; import Feed from './Feed'; +import { InnertubeError, throwIfMissing } from '../utils/Utils'; + class PlaylistManager { #actions; + constructor(actions: Actions) { this.#actions = actions; } + /** * Creates a playlist. */ async create(title: string, video_ids: string[]) { throwIfMissing({ title, video_ids }); + const response = await this.#actions.execute('/playlist/create', { title, ids: video_ids, parse: false }); + return { success: response.success, status_code: response.status_code, @@ -21,12 +26,15 @@ class PlaylistManager { data: response.data }; } + /** * Deletes a given playlist. */ async delete(playlist_id: string) { throwIfMissing({ playlist_id }); + const response = await this.#actions.execute('playlist/delete', { playlistId: playlist_id }); + return { playlist_id, success: response.success, @@ -34,11 +42,13 @@ class PlaylistManager { data: response.data }; } + /** * Adds videos to a given playlist. */ async addVideos(playlist_id: string, video_ids: string[]) { throwIfMissing({ playlist_id, video_ids }); + const response = await this.#actions.execute('/browse/edit_playlist', { playlistId: playlist_id, actions: video_ids.map((id) => ({ @@ -47,11 +57,13 @@ class PlaylistManager { })), parse: false }); + return { playlist_id, action_result: response.data.actions // TODO: implement actions in the parser }; } + /** * Removes videos from a given playlist. */ @@ -67,9 +79,9 @@ class PlaylistManager { const payload = { playlistId: playlist_id, actions: [] as { - action: string; - setVideoId: string; - }[] + action: string; + setVideoId: string; + }[] }; const getSetVideoIds = async (pl: Feed): Promise => { @@ -116,14 +128,13 @@ class PlaylistManager { const payload = { playlistId: playlist_id, actions: [] as { - action: string, - setVideoId?: string, - movedSetVideoIdPredecessor?: string - }[] + action: string, + setVideoId?: string, + movedSetVideoIdPredecessor?: string + }[] }; - let set_video_id_0: string | undefined, - set_video_id_1: string | undefined; + let set_video_id_0: string | undefined, set_video_id_1: string | undefined; const getSetVideoIds = async (pl: Feed): Promise => { const video_0 = pl.videos.find((video) => moved_video_id === video.key('id').string()); @@ -154,4 +165,5 @@ class PlaylistManager { }; } } -export default PlaylistManager; + +export default PlaylistManager; \ No newline at end of file diff --git a/src/core/Session.ts b/src/core/Session.ts index 724e23ba..d0939933 100644 --- a/src/core/Session.ts +++ b/src/core/Session.ts @@ -1,57 +1,58 @@ import Player from './Player'; import Proto from '../proto/index'; -import { DeviceCategory, generateRandomString, getRandomUserAgent, InnertubeError, SessionError } from '../utils/Utils'; +import Actions from './Actions'; import Constants from '../utils/Constants'; import UniversalCache from '../utils/Cache'; -import OAuth, { Credentials, OAuthAuthErrorEventHandler, OAuthAuthEventHandler, OAuthAuthPendingEventHandler } from './OAuth'; import EventEmitterLike from '../utils/EventEmitterLike'; + import HTTPClient, { FetchFunction } from '../utils/HTTPClient'; -import Actions from './Actions'; +import { DeviceCategory, generateRandomString, getRandomUserAgent, InnertubeError, SessionError } from '../utils/Utils'; +import OAuth, { Credentials, OAuthAuthErrorEventHandler, OAuthAuthEventHandler, OAuthAuthPendingEventHandler } from './OAuth'; export interface Context { - client: { - hl: string; - gl: string; - remoteHost: string; - visitorData: string; - userAgent: string; - clientName: string; - clientVersion: string; - osName: string; - osVersion: string; - platform: string; - clientFormFactor: string; - userInterfaceTheme: string; - timeZone: string; - browserName: string; - browserVersion: string; - originalUrl: string; - deviceMake: string; - deviceModel: string; - utcOffsetMinutes: number; - }; - user: { - lockedSafetyMode: false; - }; - request: { - useSsl: true; - }; + client: { + hl: string; + gl: string; + remoteHost: string; + visitorData: string; + userAgent: string; + clientName: string; + clientVersion: string; + osName: string; + osVersion: string; + platform: string; + clientFormFactor: string; + userInterfaceTheme: string; + timeZone: string; + browserName: string; + browserVersion: string; + originalUrl: string; + deviceMake: string; + deviceModel: string; + utcOffsetMinutes: number; + }; + user: { + lockedSafetyMode: false; + }; + request: { + useSsl: true; + }; } export enum ClientType { - WEB = 'WEB', - MUSIC = 'WEB_REMIX', - ANDROID = 'ANDROID', + WEB = 'WEB', + MUSIC = 'WEB_REMIX', + ANDROID = 'ANDROID', } export interface SessionOptions { - lang?: string; - device_category?: DeviceCategory; - client_type?: ClientType; - timezone?: string; - cache?: UniversalCache; - cookie?: string; - fetch?: FetchFunction; + lang?: string; + device_category?: DeviceCategory; + client_type?: ClientType; + timezone?: string; + cache?: UniversalCache; + cookie?: string; + fetch?: FetchFunction; } export default class Session extends EventEmitterLike { @@ -63,6 +64,7 @@ export default class Session extends EventEmitterLike { http; logged_in; actions; + constructor(context: Context, api_key: string, api_version: string, player: Player, cookie?: string, fetch?: FetchFunction) { super(); this.#context = context; @@ -74,34 +76,45 @@ export default class Session extends EventEmitterLike { this.oauth = new OAuth(this); this.logged_in = !!cookie; } + on(type: 'auth', listener: OAuthAuthEventHandler): void; on(type: 'auth-pending', listener: OAuthAuthPendingEventHandler): void; on(type: 'auth-error', listener: OAuthAuthErrorEventHandler): void; + on(type: string, listener: (...args: any[]) => void): void { super.on(type, listener); } + once(type: 'auth', listener: OAuthAuthEventHandler): void; once(type: 'auth-pending', listener: OAuthAuthPendingEventHandler): void; once(type: 'auth-error', listener: OAuthAuthErrorEventHandler): void; + once(type: string, listener: (...args: any[]) => void): void { super.once(type, listener); } + async signIn(credentials?: Credentials): Promise { return new Promise(async (resolve, reject) => { const error_handler: OAuthAuthErrorEventHandler = (err) => { reject(err); }; + this.once('auth', (data) => { this.off('auth-error', error_handler); + if (data.status === 'SUCCESS') { this.logged_in = true; resolve(); - } else - reject(data); + } + + reject(data); }); + this.once('auth-error', error_handler); + try { await this.oauth.init(credentials); + if (this.oauth.validateCredentials()) { await this.oauth.checkAccessTokenValidity(); this.logged_in = true; @@ -112,17 +125,22 @@ export default class Session extends EventEmitterLike { } }); } + async signOut() { if (!this.logged_in) throw new InnertubeError('You are not signed in'); + const response = await this.oauth.revokeCredentials(); this.logged_in = false; + return response; } + static async create(options: SessionOptions = {}) { const { context, api_key, api_version } = await Session.getSessionData(options.lang, options.device_category, options.client_type, options.timezone, options.fetch); return new Session(context, api_key, api_version, await Player.create(options.cache, options.fetch), options.cookie, options.fetch); } + static async getSessionData( lang = 'en-US', deviceCategory: DeviceCategory = 'desktop', @@ -147,7 +165,6 @@ export default class Session extends EventEmitterLike { } const text = await res.text(); - const data = JSON.parse(text.replace(/^\)\]\}'/, '')); const ytcfg = data[0][2]; @@ -196,25 +213,32 @@ export default class Session extends EventEmitterLike { api_version }; } + get key() { return this.#key; } + get api_version() { return this.#api_version; } + get client_version() { return this.#context.client.clientVersion; } + get client_name() { return this.#context.client.clientName; } + get context() { return this.#context; } + get player() { return this.#player; } + get lang() { return this.#context.client.hl; } -} +} \ No newline at end of file diff --git a/src/core/TabbedFeed.ts b/src/core/TabbedFeed.ts index c9ddc701..1fbadd39 100644 --- a/src/core/TabbedFeed.ts +++ b/src/core/TabbedFeed.ts @@ -6,25 +6,33 @@ import Feed from './Feed'; class TabbedFeed extends Feed { #tabs; #actions; + constructor(actions: Actions, data: any, already_parsed = false) { super(actions, data, already_parsed); this.#actions = actions; this.#tabs = this.page.contents_memo.getType(Tab); } + get tabs() { return this.#tabs.map((tab) => tab.title.toString()); } + async getTab(title: string) { const tab = this.#tabs.find((tab) => tab.title.toLowerCase() === title.toLowerCase()); + if (!tab) throw new InnertubeError(`Tab "${title}" not found`); + if (tab.selected) return this; + const response = await tab.endpoint.call(this.#actions); return new TabbedFeed(this.#actions, response, true); } + get title() { return this.page.contents_memo.getType(Tab)?.find((tab) => tab.selected)?.title.toString(); } } -export default TabbedFeed; + +export default TabbedFeed; \ No newline at end of file diff --git a/src/parser/README.md b/src/parser/README.md index 8ca0082e..d16625cb 100644 --- a/src/parser/README.md +++ b/src/parser/README.md @@ -12,13 +12,13 @@ Sanitizes and standardizes InnerTube responses while maintaining the integrity o -#### parse(data) +#### parse(data, requireArray, validTypes) -Responsible for parsing specifically the `contents` property of the response object. +Responsible for parsing individual nodes. | Param | Type | Description | | --- | --- | --- | -| data | `any` | The contents property | +| data | `any` | The data | | requireArray | `?boolean` | Whether the response should be an array | | validTypes | `YTNodeConstructor | YTNodeConstructor[] | undefined` | The types of YTNodes are allowed | diff --git a/src/parser/classes/AnalyticsMainAppKeyMetrics.js b/src/parser/classes/AnalyticsMainAppKeyMetrics.js index 51d0e754..f381e01d 100644 --- a/src/parser/classes/AnalyticsMainAppKeyMetrics.js +++ b/src/parser/classes/AnalyticsMainAppKeyMetrics.js @@ -1,9 +1,9 @@ import DataModelSection from './DataModelSection'; - import { YTNode } from '../helpers'; class AnalyticsMainAppKeyMetrics extends YTNode { static type = 'AnalyticsMainAppKeyMetrics'; + constructor(data) { super(); this.period = data.cardData.periodLabel; @@ -11,4 +11,5 @@ class AnalyticsMainAppKeyMetrics extends YTNode { this.sections = metrics_data.dataModel.sections.map((section) => new DataModelSection(section)); } } -export default AnalyticsMainAppKeyMetrics; + +export default AnalyticsMainAppKeyMetrics; \ No newline at end of file diff --git a/src/parser/classes/AnalyticsVideo.js b/src/parser/classes/AnalyticsVideo.js index 333579fc..d924cc04 100644 --- a/src/parser/classes/AnalyticsVideo.js +++ b/src/parser/classes/AnalyticsVideo.js @@ -1,12 +1,13 @@ import Thumbnail from './misc/Thumbnail'; - import { YTNode } from '../helpers'; class AnalyticsVideo extends YTNode { static type = 'AnalyticsVideo'; + constructor(data) { super(); this.title = data.videoTitle; + this.metadata = { views: data.videoDescription.split('·')[0].trim(), published: data.videoDescription.split('·')[1].trim(), @@ -16,4 +17,5 @@ class AnalyticsVideo extends YTNode { }; } } -export default AnalyticsVideo; + +export default AnalyticsVideo; \ No newline at end of file diff --git a/src/parser/classes/AnalyticsVodCarouselCard.js b/src/parser/classes/AnalyticsVodCarouselCard.js index cf214a23..cc617935 100644 --- a/src/parser/classes/AnalyticsVodCarouselCard.js +++ b/src/parser/classes/AnalyticsVodCarouselCard.js @@ -1,13 +1,14 @@ import Video from './AnalyticsVideo'; - import { YTNode } from '../helpers'; class AnalyticsVodCarouselCard extends YTNode { static type = 'AnalyticsVodCarouselCard'; + constructor(data) { super(); this.title = data.title; this.videos = data.videoCarouselData.videos.map((video) => new Video(video)); } } -export default AnalyticsVodCarouselCard; + +export default AnalyticsVodCarouselCard; \ No newline at end of file diff --git a/src/parser/classes/BackstageImage.js b/src/parser/classes/BackstageImage.js index 3a9f69d1..208defad 100644 --- a/src/parser/classes/BackstageImage.js +++ b/src/parser/classes/BackstageImage.js @@ -1,12 +1,13 @@ import Thumbnail from './misc/Thumbnail'; - import { YTNode } from '../helpers'; class BackstageImage extends YTNode { static type = 'BackstageImage'; + constructor(data) { super(); this.image = Thumbnail.fromResponse(data.image); } } -export default BackstageImage; + +export default BackstageImage; \ No newline at end of file diff --git a/src/parser/classes/BackstagePost.js b/src/parser/classes/BackstagePost.js index 3db4e0e7..ae6e20b9 100644 --- a/src/parser/classes/BackstagePost.js +++ b/src/parser/classes/BackstagePost.js @@ -7,13 +7,16 @@ import { YTNode } from '../helpers'; class BackstagePost extends YTNode { static type = 'BackstagePost'; + constructor(data) { super(); this.id = data.postId; + this.author = new Author({ ...data.authorText, navigationEndpoint: data.authorEndpoint }, null, data.authorThumbnail); + this.content = new Text(data.contentText, ''); this.published = new Text(data.publishedTimeText); this.poll_status = data.pollStatus; @@ -27,4 +30,5 @@ class BackstagePost extends YTNode { this.attachment = Parser.parse(data.backstageAttachment) || null; } } -export default BackstagePost; + +export default BackstagePost; \ No newline at end of file diff --git a/src/parser/classes/BackstagePostThread.js b/src/parser/classes/BackstagePostThread.js index 11785a47..2406daa9 100644 --- a/src/parser/classes/BackstagePostThread.js +++ b/src/parser/classes/BackstagePostThread.js @@ -1,12 +1,13 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class BackstagePostThread extends YTNode { static type = 'BackstagePostThread'; + constructor(data) { super(); this.post = Parser.parse(data.post); } } -export default BackstagePostThread; + +export default BackstagePostThread; \ No newline at end of file diff --git a/src/parser/classes/BrowseFeedActions.js b/src/parser/classes/BrowseFeedActions.js index 2c64e6eb..9b05b034 100644 --- a/src/parser/classes/BrowseFeedActions.js +++ b/src/parser/classes/BrowseFeedActions.js @@ -1,12 +1,13 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class BrowseFeedActions extends YTNode { static type = 'BrowseFeedActions'; + constructor(data) { super(); this.contents = Parser.parse(data.contents); } } -export default BrowseFeedActions; + +export default BrowseFeedActions; \ No newline at end of file diff --git a/src/parser/classes/Button.js b/src/parser/classes/Button.js index 84f4b5ae..07f8b0ad 100644 --- a/src/parser/classes/Button.js +++ b/src/parser/classes/Button.js @@ -8,16 +8,21 @@ class Button extends YTNode { constructor(data) { super(); this.text = new Text(data.text).toString(); + if (data.accessibility?.label) { this.label = data.accessibility?.label; } + if (data.tooltip) { this.tooltip = data.tooltip; } + if (data.icon?.iconType) { this.iconType = data.icon?.iconType; } + this.endpoint = new NavigationEndpoint(data.navigationEndpoint || data.serviceEndpoint || data.command); } } -export default Button; + +export default Button; \ No newline at end of file diff --git a/src/parser/classes/C4TabbedHeader.js b/src/parser/classes/C4TabbedHeader.js index 6cf70cd8..eba8ac4a 100644 --- a/src/parser/classes/C4TabbedHeader.js +++ b/src/parser/classes/C4TabbedHeader.js @@ -2,17 +2,18 @@ import Parser from '../index'; import Author from './misc/Author'; import Thumbnail from './misc/Thumbnail'; import Text from './misc/Text'; - import { YTNode } from '../helpers'; class C4TabbedHeader extends YTNode { static type = 'C4TabbedHeader'; + constructor(data) { super(); this.author = new Author({ simpleText: data.title, navigationEndpoint: data.navigationEndpoint }, data.badges, data.avatar); + this.banner = data.banner ? Thumbnail.fromResponse(data.banner) : []; this.tv_banner = data.tvBanner ? Thumbnail.fromResponse(data.tvBanner) : []; this.mobile_banner = data.mobileBanner ? Thumbnail.fromResponse(data.mobileBanner) : []; @@ -22,4 +23,5 @@ class C4TabbedHeader extends YTNode { this.header_links = data.headerLinks && Parser.parse(data.headerLinks); } } -export default C4TabbedHeader; + +export default C4TabbedHeader; \ No newline at end of file diff --git a/src/parser/classes/CallToActionButton.js b/src/parser/classes/CallToActionButton.js index 93070f2e..d4bbf6cb 100644 --- a/src/parser/classes/CallToActionButton.js +++ b/src/parser/classes/CallToActionButton.js @@ -1,9 +1,9 @@ import Text from './misc/Text'; - import { YTNode } from '../helpers'; class CallToActionButton extends YTNode { static type = 'CallToActionButton'; + constructor(data) { super(); this.label = new Text(data.label); @@ -11,4 +11,5 @@ class CallToActionButton extends YTNode { this.style = data.style; } } -export default CallToActionButton; + +export default CallToActionButton; \ No newline at end of file diff --git a/src/parser/classes/Card.js b/src/parser/classes/Card.js index d572c360..feea6bf7 100644 --- a/src/parser/classes/Card.js +++ b/src/parser/classes/Card.js @@ -1,15 +1,16 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class Card extends YTNode { static type = 'Card'; + constructor(data) { super(); 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, @@ -18,4 +19,5 @@ class Card extends YTNode { })); } } -export default Card; + +export default Card; \ No newline at end of file diff --git a/src/parser/classes/CardCollection.js b/src/parser/classes/CardCollection.js index 2f094468..25752acf 100644 --- a/src/parser/classes/CardCollection.js +++ b/src/parser/classes/CardCollection.js @@ -1,10 +1,10 @@ import Parser from '../index'; import Text from './misc/Text'; - import { YTNode } from '../helpers'; class CardCollection extends YTNode { static type = 'CardCollection'; + constructor(data) { super(); this.cards = Parser.parse(data.cards); @@ -12,4 +12,5 @@ class CardCollection extends YTNode { this.allow_teaser_dismiss = data.allowTeaserDismiss; } } -export default CardCollection; + +export default CardCollection; \ No newline at end of file diff --git a/src/parser/classes/Channel.js b/src/parser/classes/Channel.js index 67a4ff09..745eb203 100644 --- a/src/parser/classes/Channel.js +++ b/src/parser/classes/Channel.js @@ -1,22 +1,25 @@ import Author from './misc/Author'; import NavigationEndpoint from './NavigationEndpoint'; import Text from './misc/Text'; - import { YTNode } from '../helpers'; class Channel extends YTNode { static type = 'Channel'; + constructor(data) { super(); this.id = data.channelId; + this.author = new Author({ ...data.title, navigationEndpoint: data.navigationEndpoint }, data.ownerBadges, data.thumbnail); + this.subscribers = new Text(data.subscriberCountText); this.videos = new Text(data.videoCountText); this.endpoint = new NavigationEndpoint(data.navigationEndpoint); this.description_snippet = new Text(data.descriptionSnippet); } } -export default Channel; + +export default Channel; \ No newline at end of file diff --git a/src/parser/classes/ChannelAboutFullMetadata.js b/src/parser/classes/ChannelAboutFullMetadata.js index 28291ed3..0238f870 100644 --- a/src/parser/classes/ChannelAboutFullMetadata.js +++ b/src/parser/classes/ChannelAboutFullMetadata.js @@ -2,11 +2,11 @@ import Thumbnail from './misc/Thumbnail'; import NavigationEndpoint from './NavigationEndpoint'; import Text from './misc/Text'; import Parser from '../index'; - import { YTNode } from '../helpers'; class ChannelAboutFullMetadata extends YTNode { static type = 'ChannelAboutFullMetadata'; + constructor(data) { super(); this.id = data.channelId; @@ -22,4 +22,5 @@ class ChannelAboutFullMetadata extends YTNode { this.buttons = Parser.parse(data.actionButtons); } } -export default ChannelAboutFullMetadata; + +export default ChannelAboutFullMetadata; \ No newline at end of file diff --git a/src/parser/classes/ChannelFeaturedContent.js b/src/parser/classes/ChannelFeaturedContent.js index e9e34b49..7b6a34e0 100644 --- a/src/parser/classes/ChannelFeaturedContent.js +++ b/src/parser/classes/ChannelFeaturedContent.js @@ -1,14 +1,15 @@ import Parser from '../index'; import Text from './misc/Text'; - import { YTNode } from '../helpers'; class ChannelFeaturedContent extends YTNode { static type = 'ChannelFeaturedContent'; + constructor(data) { super(); this.title = new Text(data.title); this.items = Parser.parse(data.items); } } -export default ChannelFeaturedContent; + +export default ChannelFeaturedContent; \ No newline at end of file diff --git a/src/parser/classes/ChannelHeaderLinks.js b/src/parser/classes/ChannelHeaderLinks.js index 0246e085..2ac4b9d0 100644 --- a/src/parser/classes/ChannelHeaderLinks.js +++ b/src/parser/classes/ChannelHeaderLinks.js @@ -1,6 +1,7 @@ import NavigationEndpoint from './NavigationEndpoint'; import Text from './misc/Text'; import Thumbnail from './misc/Thumbnail'; +import { YTNode } from '../helpers'; class HeaderLink { constructor(data) { @@ -9,14 +10,15 @@ class HeaderLink { this.title = new Text(data.title); } } -import { YTNode } from '../helpers'; class ChannelHeaderLinks extends YTNode { static type = 'ChannelHeaderLinks'; + constructor(data) { super(); this.primary = data.primaryLinks?.map((link) => new HeaderLink(link)) || []; this.secondary = data.secondaryLinks?.map((link) => new HeaderLink(link)) || []; } } -export default ChannelHeaderLinks; + +export default ChannelHeaderLinks; \ No newline at end of file diff --git a/src/parser/classes/ChannelMetadata.js b/src/parser/classes/ChannelMetadata.js index 6944137c..9fec9b23 100644 --- a/src/parser/classes/ChannelMetadata.js +++ b/src/parser/classes/ChannelMetadata.js @@ -1,9 +1,9 @@ import Thumbnail from './misc/Thumbnail'; - import { YTNode } from '../helpers'; class ChannelMetadata extends YTNode { static type = 'ChannelMetadata'; + constructor(data) { super(); this.title = data.title; @@ -21,4 +21,5 @@ class ChannelMetadata extends YTNode { this.ios_appindexing_link = data.iosAppindexingLink; } } -export default ChannelMetadata; + +export default ChannelMetadata; \ No newline at end of file diff --git a/src/parser/classes/ChannelMobileHeader.js b/src/parser/classes/ChannelMobileHeader.js index 38e44675..45afc384 100644 --- a/src/parser/classes/ChannelMobileHeader.js +++ b/src/parser/classes/ChannelMobileHeader.js @@ -1,12 +1,13 @@ import Text from './misc/Text'; - import { YTNode } from '../helpers'; class ChannelMobileHeader extends YTNode { static type = 'ChannelMobileHeader'; + constructor(data) { super(); this.title = new Text(data.title); } } -export default ChannelMobileHeader; + +export default ChannelMobileHeader; \ No newline at end of file diff --git a/src/parser/classes/ChannelThumbnailWithLink.js b/src/parser/classes/ChannelThumbnailWithLink.js index 614ab141..022c4111 100644 --- a/src/parser/classes/ChannelThumbnailWithLink.js +++ b/src/parser/classes/ChannelThumbnailWithLink.js @@ -1,10 +1,10 @@ import Thumbnail from './misc/Thumbnail'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class ChannelThumbnailWithLink extends YTNode { static type = 'ChannelThumbnailWithLink'; + constructor(data) { super(); this.thumbnails = Thumbnail.fromResponse(data.thumbnail); @@ -12,4 +12,5 @@ class ChannelThumbnailWithLink extends YTNode { this.label = data.accessibility.accessibilityData.label; } } -export default ChannelThumbnailWithLink; + +export default ChannelThumbnailWithLink; \ No newline at end of file diff --git a/src/parser/classes/ChannelVideoPlayer.js b/src/parser/classes/ChannelVideoPlayer.js index 2bef8c88..65997397 100644 --- a/src/parser/classes/ChannelVideoPlayer.js +++ b/src/parser/classes/ChannelVideoPlayer.js @@ -1,9 +1,9 @@ import Text from './misc/Text'; - import { YTNode } from '../helpers'; class ChannelVideoPlayer extends YTNode { static type = 'ChannelVideoPlayer'; + constructor(data) { super(); this.id = data.videoId; @@ -13,4 +13,5 @@ class ChannelVideoPlayer extends YTNode { this.published_at = new Text(data.publishedTimeText, ''); } } -export default ChannelVideoPlayer; + +export default ChannelVideoPlayer; \ No newline at end of file diff --git a/src/parser/classes/ChildVideo.js b/src/parser/classes/ChildVideo.js index 11f06f78..cd12e7b9 100644 --- a/src/parser/classes/ChildVideo.js +++ b/src/parser/classes/ChildVideo.js @@ -1,20 +1,24 @@ import NavigationEndpoint from './NavigationEndpoint'; -import { timeToSeconds } from '../../utils/Utils'; import Text from './misc/Text'; +import { timeToSeconds } from '../../utils/Utils'; import { YTNode } from '../helpers'; class ChildVideo extends YTNode { static type = 'ChildVideo'; + constructor(data) { super(); this.id = data.videoId; this.title = new Text(data.title); + this.duration = { text: data.lengthText.simpleText, seconds: timeToSeconds(data.lengthText.simpleText) }; + this.endpoint = new NavigationEndpoint(data.navigationEndpoint); } } + export default ChildVideo; diff --git a/src/parser/classes/ChipCloud.js b/src/parser/classes/ChipCloud.js index a42991eb..80cb0aee 100644 --- a/src/parser/classes/ChipCloud.js +++ b/src/parser/classes/ChipCloud.js @@ -14,4 +14,5 @@ class ChipCloud extends YTNode { this.horizontal_scrollable = data.horizontalScrollable; } } -export default ChipCloud; + +export default ChipCloud; \ No newline at end of file diff --git a/src/parser/classes/ChipCloudChip.js b/src/parser/classes/ChipCloudChip.js index 0e6e490f..23f5afe4 100644 --- a/src/parser/classes/ChipCloudChip.js +++ b/src/parser/classes/ChipCloudChip.js @@ -1,10 +1,10 @@ import Text from './misc/Text'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class ChipCloudChip extends YTNode { static type = 'ChipCloudChip'; + constructor(data) { super(); // TODO: is this isSelected or just selected @@ -13,4 +13,5 @@ class ChipCloudChip extends YTNode { this.text = new Text(data.text).toString(); } } -export default ChipCloudChip; + +export default ChipCloudChip; \ No newline at end of file diff --git a/src/parser/classes/CollageHeroImage.js b/src/parser/classes/CollageHeroImage.js index 194569d6..ffb53fec 100644 --- a/src/parser/classes/CollageHeroImage.js +++ b/src/parser/classes/CollageHeroImage.js @@ -1,10 +1,10 @@ import NavigationEndpoint from './NavigationEndpoint'; import Thumbnail from './misc/Thumbnail'; - import { YTNode } from '../helpers'; class CollageHeroImage extends YTNode { static type = 'CollageHeroImage'; + constructor(data) { super(); this.left = Thumbnail.fromResponse(data.leftThumbnail); @@ -13,4 +13,5 @@ class CollageHeroImage extends YTNode { this.endpoint = new NavigationEndpoint(data.navigationEndpoint); } } -export default CollageHeroImage; + +export default CollageHeroImage; \ No newline at end of file diff --git a/src/parser/classes/CompactLink.js b/src/parser/classes/CompactLink.js index a8e5ccd7..5f570634 100644 --- a/src/parser/classes/CompactLink.js +++ b/src/parser/classes/CompactLink.js @@ -1,10 +1,10 @@ import Text from './misc/Text'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class CompactLink extends YTNode { static type = 'CompactLink'; + constructor(data) { super(); this.title = new Text(data.title).toString(); @@ -12,4 +12,5 @@ class CompactLink extends YTNode { this.style = data.style; } } -export default CompactLink; + +export default CompactLink; \ No newline at end of file diff --git a/src/parser/classes/CompactMix.js b/src/parser/classes/CompactMix.js index b92145a0..d41d15b9 100644 --- a/src/parser/classes/CompactMix.js +++ b/src/parser/classes/CompactMix.js @@ -2,8 +2,10 @@ import Playlist from './Playlist'; class CompactMix extends Playlist { static type = 'CompactMix'; + constructor(data) { super(data); } } -export default CompactMix; + +export default CompactMix; \ No newline at end of file diff --git a/src/parser/classes/CompactPlaylist.js b/src/parser/classes/CompactPlaylist.js index eaa35570..3fbcfaf9 100644 --- a/src/parser/classes/CompactPlaylist.js +++ b/src/parser/classes/CompactPlaylist.js @@ -2,8 +2,10 @@ import Playlist from './Playlist'; class CompactPlaylist extends Playlist { static type = 'CompactPlaylist'; + constructor(data) { super(data); } } -export default CompactPlaylist; + +export default CompactPlaylist; \ No newline at end of file diff --git a/src/parser/classes/CompactVideo.js b/src/parser/classes/CompactVideo.js index 85fad03d..42c64da0 100644 --- a/src/parser/classes/CompactVideo.js +++ b/src/parser/classes/CompactVideo.js @@ -4,11 +4,11 @@ import Author from './misc/Author'; import { timeToSeconds } from '../../utils/Utils'; import Thumbnail from './misc/Thumbnail'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class CompactVideo extends YTNode { static type = 'CompactVideo'; + constructor(data) { super(); this.id = data.videoId; @@ -19,16 +19,20 @@ class CompactVideo extends YTNode { this.view_count = new Text(data.viewCountText); this.short_view_count = new Text(data.shortViewCountText); this.published = new Text(data.publishedTimeText); + this.duration = { text: new Text(data.lengthText).toString(), seconds: timeToSeconds(new Text(data.lengthText).toString()) }; + this.thumbnail_overlays = Parser.parse(data.thumbnailOverlays); this.endpoint = new NavigationEndpoint(data.navigationEndpoint); this.menu = Parser.parse(data.menu); } + get best_thumbnail() { return this.thumbnails[0]; } } -export default CompactVideo; + +export default CompactVideo; \ No newline at end of file diff --git a/src/parser/classes/ContinuationItem.js b/src/parser/classes/ContinuationItem.js index 2434771b..46dd543b 100644 --- a/src/parser/classes/ContinuationItem.js +++ b/src/parser/classes/ContinuationItem.js @@ -1,16 +1,20 @@ import Parser from '../index'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class ContinuationItem extends YTNode { static type = 'ContinuationItem'; + constructor(data) { super(); this.trigger = data.trigger; - data.button && - (this.button = Parser.parse(data.button)); + + if (data.button) { + this.button = Parser.parse(data.button); + } + this.endpoint = new NavigationEndpoint(data.continuationEndpoint); } } -export default ContinuationItem; + +export default ContinuationItem; \ No newline at end of file diff --git a/src/parser/classes/CtaGoToCreatorStudio.js b/src/parser/classes/CtaGoToCreatorStudio.js index f12d1f64..22a3140d 100644 --- a/src/parser/classes/CtaGoToCreatorStudio.js +++ b/src/parser/classes/CtaGoToCreatorStudio.js @@ -1,8 +1,8 @@ - import { YTNode } from '../helpers'; class CtaGoToCreatorStudio extends YTNode { static type = 'CtaGoToCreatorStudio'; + constructor(data) { super(); this.title = data.buttonLabel; @@ -10,4 +10,5 @@ class CtaGoToCreatorStudio extends YTNode { // Is this even useful? } } -export default CtaGoToCreatorStudio; + +export default CtaGoToCreatorStudio; \ No newline at end of file diff --git a/src/parser/classes/DataModelSection.js b/src/parser/classes/DataModelSection.js index fe8e9cd8..971e00eb 100644 --- a/src/parser/classes/DataModelSection.js +++ b/src/parser/classes/DataModelSection.js @@ -1,14 +1,15 @@ - import { YTNode } from '../helpers'; class DataModelSection extends YTNode { static type = 'DataModelSection'; + constructor(data) { super(); 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, @@ -18,4 +19,5 @@ class DataModelSection extends YTNode { }; } } -export default DataModelSection; + +export default DataModelSection; \ No newline at end of file diff --git a/src/parser/classes/DidYouMean.js b/src/parser/classes/DidYouMean.js index 801320b4..218fa704 100644 --- a/src/parser/classes/DidYouMean.js +++ b/src/parser/classes/DidYouMean.js @@ -1,14 +1,15 @@ import Text from './misc/Text'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class DidYouMean extends YTNode { static type = 'DidYouMean'; + constructor(data) { super(); this.corrected_query = new Text(data.correctedQuery); this.endpoint = new NavigationEndpoint(data.navigationEndpoint); } } -export default DidYouMean; + +export default DidYouMean; \ No newline at end of file diff --git a/src/parser/classes/DownloadButton.js b/src/parser/classes/DownloadButton.js index 23ed85e5..a03ed14b 100644 --- a/src/parser/classes/DownloadButton.js +++ b/src/parser/classes/DownloadButton.js @@ -1,9 +1,9 @@ import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class DownloadButton extends YTNode { static type = 'DownloadButton'; + constructor(data) { super(); this.style = data.style; @@ -12,4 +12,5 @@ class DownloadButton extends YTNode { this.target_id = data.targetId; } } -export default DownloadButton; + +export default DownloadButton; \ No newline at end of file diff --git a/src/parser/classes/Element.js b/src/parser/classes/Element.js index 04154209..dad8713e 100644 --- a/src/parser/classes/Element.js +++ b/src/parser/classes/Element.js @@ -1,13 +1,14 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class Element extends YTNode { static type = 'Element'; + constructor(data) { super(); const type = data.newElement.type.componentType; return Parser.parse(type.model); } } -export default Element; + +export default Element; \ No newline at end of file diff --git a/src/parser/classes/EmergencyOnebox.js b/src/parser/classes/EmergencyOnebox.js index 2eda981a..35696570 100644 --- a/src/parser/classes/EmergencyOnebox.js +++ b/src/parser/classes/EmergencyOnebox.js @@ -1,10 +1,10 @@ import Text from './misc/Text'; import Parser from '../index'; - import { YTNode } from '../helpers'; class EmergencyOnebox extends YTNode { static type = 'EmergencyOnebox'; + constructor(data) { super(); this.title = new Text(data.title); @@ -12,4 +12,5 @@ class EmergencyOnebox extends YTNode { this.menu = Parser.parse(data.menu); } } -export default EmergencyOnebox; + +export default EmergencyOnebox; \ No newline at end of file diff --git a/src/parser/classes/EmojiRun.js b/src/parser/classes/EmojiRun.js index d698aec8..36ec3e01 100644 --- a/src/parser/classes/EmojiRun.js +++ b/src/parser/classes/EmojiRun.js @@ -1,14 +1,17 @@ import Thumbnail from './misc/Thumbnail'; - import { YTNode } from '../helpers'; class EmojiRun extends YTNode { static type = 'EmojiRun'; + constructor(data) { super(); + this.text = - data.emoji?.emojiId || - data.emoji?.shortcuts?.[0] || null; + data.emoji?.emojiId || + data.emoji?.shortcuts?.[0] || + null; + this.emoji = { emoji_id: data.emoji.emojiId, shortcuts: data.emoji.shortcuts, @@ -17,4 +20,5 @@ class EmojiRun extends YTNode { }; } } -export default EmojiRun; + +export default EmojiRun; \ No newline at end of file diff --git a/src/parser/classes/EndScreenPlaylist.js b/src/parser/classes/EndScreenPlaylist.js index 85f7bf9b..b2a8875f 100644 --- a/src/parser/classes/EndScreenPlaylist.js +++ b/src/parser/classes/EndScreenPlaylist.js @@ -1,7 +1,6 @@ import Text from './misc/Text'; import Thumbnail from './misc/Thumbnail'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class EndScreenPlaylist extends YTNode { @@ -16,4 +15,5 @@ class EndScreenPlaylist extends YTNode { this.video_count = new Text(data.videoCountText); } } -export default EndScreenPlaylist; + +export default EndScreenPlaylist; \ No newline at end of file diff --git a/src/parser/classes/EndScreenVideo.js b/src/parser/classes/EndScreenVideo.js index e3b026a0..2a90dda7 100644 --- a/src/parser/classes/EndScreenVideo.js +++ b/src/parser/classes/EndScreenVideo.js @@ -3,11 +3,11 @@ import Text from './misc/Text'; import Author from './misc/Author'; import Thumbnail from './misc/Thumbnail'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class EndScreenVideo extends YTNode { static type = 'EndScreenVideo'; + constructor(data) { super(); this.id = data.videoId; @@ -24,4 +24,5 @@ class EndScreenVideo extends YTNode { }; } } -export default EndScreenVideo; + +export default EndScreenVideo; \ No newline at end of file diff --git a/src/parser/classes/Endscreen.js b/src/parser/classes/Endscreen.js index 57abe0ca..c683fc66 100644 --- a/src/parser/classes/Endscreen.js +++ b/src/parser/classes/Endscreen.js @@ -1,13 +1,14 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class Endscreen extends YTNode { static type = 'Endscreen'; + constructor(data) { super(); this.elements = Parser.parse(data.elements); this.start_ms = data.startMs; } } -export default Endscreen; + +export default Endscreen; \ No newline at end of file diff --git a/src/parser/classes/EndscreenElement.js b/src/parser/classes/EndscreenElement.js index c38a5c52..accbd7f9 100644 --- a/src/parser/classes/EndscreenElement.js +++ b/src/parser/classes/EndscreenElement.js @@ -2,34 +2,42 @@ import Parser from '../index'; import Thumbnail from './misc/Thumbnail'; import NavigationEndpoint from './NavigationEndpoint'; import Text from './misc/Text'; - import { YTNode } from '../helpers'; class EndscreenElement extends YTNode { static type = 'EndscreenElement'; + constructor(data) { super(); + this.style = data.style; this.title = new Text(data.title); this.endpoint = new NavigationEndpoint(data.endpoint); + if (data.image) { this.image = Thumbnail.fromResponse(data.image); } + if (data.icon) { this.icon = Thumbnail.fromResponse(data.icon); } + if (data.metadata) { this.metadata = new Text(data.metadata); } + if (data.callToAction) { this.call_to_action = new Text(data.callToAction); } + if (data.hovercardButton) { this.hovercard_button = Parser.parse(data.hovercardButton); } + if (data.isSubscribe) { this.is_subscribe = data.isSubscribe; } + this.thumbnail_overlays = Parser.parse(data.thumbnailOverlays); this.left = data.left; this.width = data.width; @@ -40,4 +48,5 @@ class EndscreenElement extends YTNode { this.id = data.id; } } -export default EndscreenElement; + +export default EndscreenElement; \ No newline at end of file diff --git a/src/parser/classes/ExpandableTab.js b/src/parser/classes/ExpandableTab.js index a181adda..1f795501 100644 --- a/src/parser/classes/ExpandableTab.js +++ b/src/parser/classes/ExpandableTab.js @@ -1,10 +1,10 @@ import Parser from '../index'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class ExpandableTab extends YTNode { static type = 'ExpandableTab'; + constructor(data) { super(); this.title = data.title; @@ -13,4 +13,5 @@ class ExpandableTab extends YTNode { this.content = data.content ? Parser.parse(data.content) : null; } } -export default ExpandableTab; + +export default ExpandableTab; \ No newline at end of file diff --git a/src/parser/classes/ExpandedShelfContents.js b/src/parser/classes/ExpandedShelfContents.js index b41ac2bf..8c321dde 100644 --- a/src/parser/classes/ExpandedShelfContents.js +++ b/src/parser/classes/ExpandedShelfContents.js @@ -1,16 +1,18 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class ExpandedShelfContents extends YTNode { static type = 'ExpandedShelfContents'; + constructor(data) { super(); this.items = Parser.parse(data.items); } + // XXX: alias for consistency get contents() { return this.items; } } -export default ExpandedShelfContents; + +export default ExpandedShelfContents; \ No newline at end of file diff --git a/src/parser/classes/FeedFilterChipBar.js b/src/parser/classes/FeedFilterChipBar.js index 0827ab59..421fe489 100644 --- a/src/parser/classes/FeedFilterChipBar.js +++ b/src/parser/classes/FeedFilterChipBar.js @@ -1,12 +1,13 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class FeedFilterChipBar extends YTNode { static type = 'FeedFilterChipBar'; + constructor(data) { super(); this.contents = Parser.parse(data.contents); } } -export default FeedFilterChipBar; + +export default FeedFilterChipBar; \ No newline at end of file diff --git a/src/parser/classes/FeedTabbedHeader.js b/src/parser/classes/FeedTabbedHeader.js index 8cacd4ce..d94801e3 100644 --- a/src/parser/classes/FeedTabbedHeader.js +++ b/src/parser/classes/FeedTabbedHeader.js @@ -1,12 +1,12 @@ import Text from './misc/Text'; - import { YTNode } from '../helpers'; class FeedTabbedHeader extends YTNode { static type = 'FeedTabbedHeader'; + constructor(data) { super(); this.title = new Text(data.title); } } -export default FeedTabbedHeader; +export default FeedTabbedHeader; \ No newline at end of file diff --git a/src/parser/classes/Grid.js b/src/parser/classes/Grid.js index 5ee81913..ed710659 100644 --- a/src/parser/classes/Grid.js +++ b/src/parser/classes/Grid.js @@ -1,9 +1,9 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class Grid extends YTNode { static type = 'Grid'; + constructor(data) { super(); this.items = Parser.parse(data.items); @@ -11,9 +11,11 @@ class Grid extends YTNode { this.visible_row_count = data.visibleRowCount; this.target_id = data.targetId; } + // XXX: alias for consistency get contents() { return this.items; } } -export default Grid; + +export default Grid; \ No newline at end of file diff --git a/src/parser/classes/GridChannel.js b/src/parser/classes/GridChannel.js index 868349ed..17529723 100644 --- a/src/parser/classes/GridChannel.js +++ b/src/parser/classes/GridChannel.js @@ -2,22 +2,25 @@ import Author from './misc/Author'; import Parser from '../index'; import NavigationEndpoint from './NavigationEndpoint'; import Text from './misc/Text'; - import { YTNode } from '../helpers'; class GridChannel extends YTNode { static type = 'GridChannel'; + constructor(data) { super(); this.id = data.channelId; + this.author = new Author({ ...data.title, navigationEndpoint: data.navigationEndpoint }, data.ownerBadges, data.thumbnail); + this.subscribers = new Text(data.subscriberCountText); this.video_count = new Text(data.videoCountText); this.endpoint = new NavigationEndpoint(data.navigationEndpoint); this.subscribe_button = Parser.parse(data.subscribeButton); } } -export default GridChannel; + +export default GridChannel; \ No newline at end of file diff --git a/src/parser/classes/GridPlaylist.js b/src/parser/classes/GridPlaylist.js index 8001974a..0c3005c2 100644 --- a/src/parser/classes/GridPlaylist.js +++ b/src/parser/classes/GridPlaylist.js @@ -4,18 +4,20 @@ import Thumbnail from './misc/Thumbnail'; import PlaylistAuthor from './misc/PlaylistAuthor'; import NavigationEndpoint from './NavigationEndpoint'; import NavigatableText from './misc/NavigatableText'; - import { YTNode } from '../helpers'; class GridPlaylist extends YTNode { static type = 'GridPlaylist'; + constructor(data) { super(); this.id = data.playlistId; this.title = new Text(data.title); + if (data.shortBylineText) { this.author = new PlaylistAuthor(data.shortBylineText, data.ownerBadges); } + this.badges = Parser.parse(data.ownerBadges); this.endpoint = new NavigationEndpoint(data.navigationEndpoint); this.view_playlist = new NavigatableText(data.viewPlaylistText); @@ -26,4 +28,5 @@ class GridPlaylist extends YTNode { this.video_count_short_text = new Text(data.videoCountShortText); } } -export default GridPlaylist; + +export default GridPlaylist; \ No newline at end of file diff --git a/src/parser/classes/GridVideo.js b/src/parser/classes/GridVideo.js index 77511e82..44c191e6 100644 --- a/src/parser/classes/GridVideo.js +++ b/src/parser/classes/GridVideo.js @@ -3,11 +3,11 @@ import Text from './misc/Text'; import Thumbnail from './misc/Thumbnail'; import NavigationEndpoint from './NavigationEndpoint'; import Author from './misc/Author'; - import { YTNode } from '../helpers'; class GridVideo extends YTNode { static type = 'GridVideo'; + constructor(data) { super(); const length_alt = data.thumbnailOverlays.find((overlay) => overlay.hasOwnProperty('thumbnailOverlayTimeStatusRenderer'))?.thumbnailOverlayTimeStatusRenderer; @@ -25,4 +25,5 @@ class GridVideo extends YTNode { this.menu = Parser.parse(data.menu); } } -export default GridVideo; + +export default GridVideo; \ No newline at end of file diff --git a/src/parser/classes/HistorySuggestion.js b/src/parser/classes/HistorySuggestion.js index 2b106c6a..d68e057e 100644 --- a/src/parser/classes/HistorySuggestion.js +++ b/src/parser/classes/HistorySuggestion.js @@ -2,8 +2,10 @@ import SearchSuggestion from './SearchSuggestion'; class HistorySuggestion extends SearchSuggestion { static type = 'HistorySuggestion'; + constructor(data) { super(data); } } -export default HistorySuggestion; + +export default HistorySuggestion; \ No newline at end of file diff --git a/src/parser/classes/HorizontalCardList.js b/src/parser/classes/HorizontalCardList.js index b7c65cb5..e7cd7fca 100644 --- a/src/parser/classes/HorizontalCardList.js +++ b/src/parser/classes/HorizontalCardList.js @@ -1,9 +1,9 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class HorizontalCardList extends YTNode { static type = 'HorizontalCardList'; + constructor(data) { super(); this.cards = Parser.parse(data.cards); @@ -12,4 +12,5 @@ class HorizontalCardList extends YTNode { this.next_button = Parser.parse(data.nextButton); } } -export default HorizontalCardList; + +export default HorizontalCardList; \ No newline at end of file diff --git a/src/parser/classes/HorizontalList.js b/src/parser/classes/HorizontalList.js index 1c85d60e..fba006f4 100644 --- a/src/parser/classes/HorizontalList.js +++ b/src/parser/classes/HorizontalList.js @@ -1,17 +1,19 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class HorizontalList extends YTNode { static type = 'HorizontalList'; + constructor(data) { super(); this.visible_item_count = data.visibleItemCount; this.items = Parser.parse(data.items); } + // XXX: alias for consistency get contents() { return this.items; } } -export default HorizontalList; + +export default HorizontalList; \ No newline at end of file diff --git a/src/parser/classes/ItemSection.js b/src/parser/classes/ItemSection.js index e9b2f01b..037e7f55 100644 --- a/src/parser/classes/ItemSection.js +++ b/src/parser/classes/ItemSection.js @@ -1,16 +1,18 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class ItemSection extends YTNode { static type = 'ItemSection'; + constructor(data) { super(); this.header = Parser.parse(data.header); this.contents = Parser.parse(data.contents, true); + if (data.targetId || data.sectionIdentifier) { this.target_id = data?.target_id || data?.sectionIdentifier; } } } -export default ItemSection; + +export default ItemSection; \ No newline at end of file diff --git a/src/parser/classes/ItemSectionHeader.js b/src/parser/classes/ItemSectionHeader.js index 25d1355d..17a822f5 100644 --- a/src/parser/classes/ItemSectionHeader.js +++ b/src/parser/classes/ItemSectionHeader.js @@ -1,12 +1,13 @@ import Text from './misc/Text'; - import { YTNode } from '../helpers'; class ItemSectionHeader extends YTNode { static type = 'ItemSectionHeader'; + constructor(data) { super(); this.title = new Text(data.title); } } -export default ItemSectionHeader; + +export default ItemSectionHeader; \ No newline at end of file diff --git a/src/parser/classes/LikeButton.js b/src/parser/classes/LikeButton.js index 51b8b03a..baa65f46 100644 --- a/src/parser/classes/LikeButton.js +++ b/src/parser/classes/LikeButton.js @@ -1,19 +1,22 @@ import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class LikeButton extends YTNode { static type = 'LikeButton'; + constructor(data) { super(); this.target = { video_id: data.target.videoId }; + this.like_status = data.likeStatus; this.likes_allowed = data.likesAllowed; + if (data.serviceEndpoints) { this.endpoints = data.serviceEndpoints?.map((endpoint) => new NavigationEndpoint(endpoint)); } } } -export default LikeButton; + +export default LikeButton; \ No newline at end of file diff --git a/src/parser/classes/LiveChat.js b/src/parser/classes/LiveChat.js index a910aa65..c749422c 100644 --- a/src/parser/classes/LiveChat.js +++ b/src/parser/classes/LiveChat.js @@ -1,15 +1,16 @@ import Parser from '../index'; import Text from './misc/Text'; - import { YTNode } from '../helpers'; class LiveChat extends YTNode { static type = 'LiveChat'; + constructor(data) { super(); this.header = Parser.parse(data.header); this.initial_display_state = data.initialDisplayState; this.continuation = data.continuations[0]?.reloadContinuationData?.continuation; + this.client_messages = { reconnect_message: new Text(data.clientMessages.reconnectMessage), unable_to_reconnect_message: new Text(data.clientMessages.unableToReconnectMessage), @@ -17,7 +18,9 @@ class LiveChat extends YTNode { reconnected_message: new Text(data.clientMessages.reconnectedMessage), generic_error: new Text(data.clientMessages.genericError) }; + this.is_replay = data.isReplay || false; } } -export default LiveChat; + +export default LiveChat; \ No newline at end of file diff --git a/src/parser/classes/LiveChatAuthorBadge.js b/src/parser/classes/LiveChatAuthorBadge.js index c1bbc376..2f1751c6 100644 --- a/src/parser/classes/LiveChatAuthorBadge.js +++ b/src/parser/classes/LiveChatAuthorBadge.js @@ -3,9 +3,11 @@ import Thumbnail from './misc/Thumbnail'; class LiveChatAuthorBadge extends MetadataBadge { static type = 'LiveChatAuthorBadge'; + constructor(data) { super(data); this.custom_thumbnail = data.customThumbnail ? Thumbnail.fromResponse(data.customThumbnail) : null; } } -export default LiveChatAuthorBadge; + +export default LiveChatAuthorBadge; \ No newline at end of file diff --git a/src/parser/classes/LiveChatHeader.js b/src/parser/classes/LiveChatHeader.js index e4efb06e..bc9218ef 100644 --- a/src/parser/classes/LiveChatHeader.js +++ b/src/parser/classes/LiveChatHeader.js @@ -1,9 +1,9 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class LiveChatHeader extends YTNode { static type = 'LiveChatHeader'; + constructor(data) { super(); this.overflow_menu = Parser.parse(data.overflowMenu); @@ -11,4 +11,5 @@ class LiveChatHeader extends YTNode { this.view_selector = Parser.parse(data.viewSelector); } } -export default LiveChatHeader; + +export default LiveChatHeader; \ No newline at end of file diff --git a/src/parser/classes/LiveChatItemList.js b/src/parser/classes/LiveChatItemList.js index 10cadd46..6b8f3edd 100644 --- a/src/parser/classes/LiveChatItemList.js +++ b/src/parser/classes/LiveChatItemList.js @@ -1,13 +1,14 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class LiveChatItemList extends YTNode { static type = 'LiveChatItemList'; + constructor(data) { super(); this.max_items_to_display = data.maxItemsToDisplay; this.more_comments_below_button = Parser.parse(data.moreCommentsBelowButton); } } -export default LiveChatItemList; + +export default LiveChatItemList; \ No newline at end of file diff --git a/src/parser/classes/LiveChatMessageInput.js b/src/parser/classes/LiveChatMessageInput.js index 52ee9804..3fc43e7a 100644 --- a/src/parser/classes/LiveChatMessageInput.js +++ b/src/parser/classes/LiveChatMessageInput.js @@ -1,11 +1,11 @@ import Text from './misc/Text'; import Parser from '../index'; import Thumbnail from './misc/Thumbnail'; - import { YTNode } from '../helpers'; class LiveChatMessageInput extends YTNode { static type = 'LiveChatMessageInput'; + constructor(data) { super(); this.author_name = new Text(data.authorName); @@ -14,4 +14,5 @@ class LiveChatMessageInput extends YTNode { this.target_id = data.targetId; } } -export default LiveChatMessageInput; + +export default LiveChatMessageInput; \ No newline at end of file diff --git a/src/parser/classes/LiveChatParticipant.js b/src/parser/classes/LiveChatParticipant.js index 1b9ef3eb..c997f97a 100644 --- a/src/parser/classes/LiveChatParticipant.js +++ b/src/parser/classes/LiveChatParticipant.js @@ -1,11 +1,11 @@ import Parser from '../index'; import Text from './misc/Text'; import Thumbnail from './misc/Thumbnail'; - import { YTNode } from '../helpers'; class LiveChatParticipant extends YTNode { static type = 'LiveChatParticipant'; + constructor(data) { super(); this.name = new Text(data.authorName); @@ -13,4 +13,5 @@ class LiveChatParticipant extends YTNode { this.badges = Parser.parse(data.authorBadges); } } -export default LiveChatParticipant; + +export default LiveChatParticipant; \ No newline at end of file diff --git a/src/parser/classes/LiveChatParticipantsList.js b/src/parser/classes/LiveChatParticipantsList.js index 4ad249fe..1e3763de 100644 --- a/src/parser/classes/LiveChatParticipantsList.js +++ b/src/parser/classes/LiveChatParticipantsList.js @@ -1,14 +1,15 @@ import Parser from '../index'; import Text from './misc/Text'; - import { YTNode } from '../helpers'; class LiveChatParticipantsList extends YTNode { static type = 'LiveChatParticipantsList'; + constructor(data) { super(); this.title = new Text(data.title); this.participants = Parser.parse(data.participants); } } -export default LiveChatParticipantsList; + +export default LiveChatParticipantsList; \ No newline at end of file diff --git a/src/parser/classes/MerchandiseItem.js b/src/parser/classes/MerchandiseItem.js index cf078cb0..4b7a4681 100644 --- a/src/parser/classes/MerchandiseItem.js +++ b/src/parser/classes/MerchandiseItem.js @@ -1,10 +1,10 @@ import Thumbnail from './misc/Thumbnail'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class MerchandiseItem extends YTNode { static type = 'MerchandiseItem'; + constructor(data) { super(); this.title = data.title; @@ -20,4 +20,5 @@ class MerchandiseItem extends YTNode { this.endpoint = new NavigationEndpoint(data.buttonCommand); } } -export default MerchandiseItem; + +export default MerchandiseItem; \ No newline at end of file diff --git a/src/parser/classes/MerchandiseShelf.js b/src/parser/classes/MerchandiseShelf.js index f96f61a6..42b4bf12 100644 --- a/src/parser/classes/MerchandiseShelf.js +++ b/src/parser/classes/MerchandiseShelf.js @@ -1,18 +1,20 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class MerchandiseShelf extends YTNode { static type = 'MerchandiseShelf'; + constructor(data) { super(); this.title = data.title; this.menu = Parser.parse(data.actionButton); this.items = Parser.parse(data.items); } + // XXX: alias for consistency get contents() { return this.items; } } -export default MerchandiseShelf; + +export default MerchandiseShelf; \ No newline at end of file diff --git a/src/parser/classes/Message.js b/src/parser/classes/Message.js index 0f6cd372..93380203 100644 --- a/src/parser/classes/Message.js +++ b/src/parser/classes/Message.js @@ -1,12 +1,13 @@ import Text from './misc/Text'; - import { YTNode } from '../helpers'; class Message extends YTNode { static type = 'Message'; + constructor(data) { super(); this.text = new Text(data.text).toString(); } } -export default Message; + +export default Message; \ No newline at end of file diff --git a/src/parser/classes/MetadataBadge.js b/src/parser/classes/MetadataBadge.js index 0f1e7be0..fba59d2e 100644 --- a/src/parser/classes/MetadataBadge.js +++ b/src/parser/classes/MetadataBadge.js @@ -1,15 +1,21 @@ - import { YTNode } from '../helpers'; class MetadataBadge extends YTNode { static type = 'MetadataBadge'; + constructor(data) { super(); - data.icon && - (this.icon_type = data.icon.iconType); - data.style && - (this.style = data.style); - this.tooltip = data.tooltip || data.iconTooltip || null; + + if (data?.icon) { + this.icon_type = data.icon.iconType; + } + + if (data?.style) { + this.style = data.style; + } + + this.tooltip = data?.tooltip || data?.iconTooltip || null; } } -export default MetadataBadge; + +export default MetadataBadge; \ No newline at end of file diff --git a/src/parser/classes/MetadataRow.ts b/src/parser/classes/MetadataRow.ts index 325bb3ac..8a74f600 100644 --- a/src/parser/classes/MetadataRow.ts +++ b/src/parser/classes/MetadataRow.ts @@ -1,15 +1,17 @@ import Text from './misc/Text'; - import { YTNode } from '../helpers'; class MetadataRow extends YTNode { static type = 'MetadataRow'; + title; contents; + constructor(data: any) { super(); this.title = new Text(data.title); this.contents = (data.contents as any[]).map((content) => new Text(content)); } } -export default MetadataRow; + +export default MetadataRow; \ No newline at end of file diff --git a/src/parser/classes/MetadataRowContainer.ts b/src/parser/classes/MetadataRowContainer.ts index 9dc6a532..62a61b07 100644 --- a/src/parser/classes/MetadataRowContainer.ts +++ b/src/parser/classes/MetadataRowContainer.ts @@ -1,15 +1,16 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class MetadataRowContainer extends YTNode { static type = 'MetadataRowContainer'; rows; collapsed_item_count: number; // TODO: validate this assumption + constructor(data: any) { super(); this.rows = Parser.parseArray(data.rows); this.collapsed_item_count = data.collapsedItemCount; } } -export default MetadataRowContainer; + +export default MetadataRowContainer; \ No newline at end of file diff --git a/src/parser/classes/MetadataRowHeader.js b/src/parser/classes/MetadataRowHeader.js index 02799e20..5258185f 100644 --- a/src/parser/classes/MetadataRowHeader.js +++ b/src/parser/classes/MetadataRowHeader.js @@ -1,13 +1,14 @@ import Text from './misc/Text'; - import { YTNode } from '../helpers'; class MetadataRowHeader extends YTNode { static type = 'MetadataRowHeader'; + constructor(data) { super(); this.content = new Text(data.content); this.has_divider_line = data.hasDividerLine; } } -export default MetadataRowHeader; + +export default MetadataRowHeader; \ No newline at end of file diff --git a/src/parser/classes/MicroformatData.js b/src/parser/classes/MicroformatData.js index eecbaed6..ab10c28d 100644 --- a/src/parser/classes/MicroformatData.js +++ b/src/parser/classes/MicroformatData.js @@ -1,9 +1,9 @@ import Thumbnail from './misc/Thumbnail'; - import { YTNode } from '../helpers'; class MicroformatData extends YTNode { static type = 'MicroformatData'; + constructor(data) { super(); this.url_canonical = data.urlCanonical; @@ -32,4 +32,5 @@ class MicroformatData extends YTNode { // XXX: linkAlternatives? } } -export default MicroformatData; + +export default MicroformatData; \ No newline at end of file diff --git a/src/parser/classes/Mix.js b/src/parser/classes/Mix.js index fe3742cb..3d426255 100644 --- a/src/parser/classes/Mix.js +++ b/src/parser/classes/Mix.js @@ -2,8 +2,10 @@ import Playlist from './Playlist'; class Mix extends Playlist { static type = 'Mix'; + constructor(data) { super(data); } } -export default Mix; + +export default Mix; \ No newline at end of file diff --git a/src/parser/classes/Movie.js b/src/parser/classes/Movie.js index 65d24619..4ea3f843 100644 --- a/src/parser/classes/Movie.js +++ b/src/parser/classes/Movie.js @@ -4,16 +4,17 @@ import Thumbnail from './misc/Thumbnail'; import NavigationEndpoint from './NavigationEndpoint'; import { timeToSeconds } from '../../utils/Utils'; import Text from './misc/Text'; - import { YTNode } from '../helpers'; class Movie extends YTNode { static type = 'Movie'; + constructor(data) { super(); const overlay_time_status = data.thumbnailOverlays .find((overlay) => overlay.thumbnailOverlayTimeStatusRenderer) ?.thumbnailOverlayTimeStatusRenderer.text || 'N/A'; + this.id = data.videoId; this.title = new Text(data.title); this.description_snippet = data.descriptionSnippet ? new Text(data.descriptionSnippet, '') : null; @@ -21,10 +22,12 @@ class Movie extends YTNode { this.thumbnails = Thumbnail.fromResponse(data.thumbnail); this.thumbnail_overlays = Parser.parse(data.thumbnailOverlays); this.author = new Author(data.longBylineText, data.ownerBadges, data.channelThumbnailSupportedRenderers?.channelThumbnailWithLinkRenderer?.thumbnail); + this.duration = { text: data.lengthText ? new Text(data.lengthText).text : new Text(overlay_time_status).text, seconds: timeToSeconds(data.lengthText ? new Text(data.lengthText).text : new Text(overlay_time_status).text) }; + this.endpoint = new NavigationEndpoint(data.navigationEndpoint); this.badges = Parser.parse(data.badges); this.use_vertical_poster = data.useVerticalPoster; @@ -32,4 +35,5 @@ class Movie extends YTNode { this.menu = Parser.parse(data.menu); } } -export default Movie; + +export default Movie; \ No newline at end of file diff --git a/src/parser/classes/MovingThumbnail.js b/src/parser/classes/MovingThumbnail.js index 463589bd..bab3fd01 100644 --- a/src/parser/classes/MovingThumbnail.js +++ b/src/parser/classes/MovingThumbnail.js @@ -1,12 +1,13 @@ import Thumbnail from './misc/Thumbnail'; - import { YTNode } from '../helpers'; class MovingThumbnail extends YTNode { static type = 'MovingThumbnail'; + constructor(data) { super(); return data.movingThumbnailDetails?.thumbnails.map((thumbnail) => new Thumbnail(thumbnail)).sort((a, b) => b.width - a.width); } } -export default MovingThumbnail; + +export default MovingThumbnail; \ No newline at end of file diff --git a/src/parser/classes/MusicCarouselShelf.js b/src/parser/classes/MusicCarouselShelf.js index c2d569d6..7a575c7e 100644 --- a/src/parser/classes/MusicCarouselShelf.js +++ b/src/parser/classes/MusicCarouselShelf.js @@ -1,9 +1,9 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class MusicCarouselShelf extends YTNode { static type = 'MusicCarouselShelf'; + constructor(data) { super(); this.header = Parser.parse(data.header); @@ -13,4 +13,5 @@ class MusicCarouselShelf extends YTNode { } } } -export default MusicCarouselShelf; + +export default MusicCarouselShelf; \ No newline at end of file diff --git a/src/parser/classes/MusicCarouselShelfBasicHeader.js b/src/parser/classes/MusicCarouselShelfBasicHeader.js index 1d92817e..3641eaed 100644 --- a/src/parser/classes/MusicCarouselShelfBasicHeader.js +++ b/src/parser/classes/MusicCarouselShelfBasicHeader.js @@ -1,15 +1,17 @@ import Text from './misc/Text'; import Thumbnail from './misc/Thumbnail'; - import { YTNode } from '../helpers'; class MusicCarouselShelfBasicHeader extends YTNode { static type = 'MusicCarouselShelfBasicHeader'; + constructor(data) { super(); + if (data.strapline) { this.strapline = new Text(data.strapline).toString(); } + this.title = new Text(data.title).toString(); // This.label = data.accessibilityData.accessibilityData.label; // ^^ redundant? @@ -18,4 +20,5 @@ class MusicCarouselShelfBasicHeader extends YTNode { } } } -export default MusicCarouselShelfBasicHeader; + +export default MusicCarouselShelfBasicHeader; \ No newline at end of file diff --git a/src/parser/classes/MusicDescriptionShelf.js b/src/parser/classes/MusicDescriptionShelf.js index d97fa7bd..3beb5797 100644 --- a/src/parser/classes/MusicDescriptionShelf.js +++ b/src/parser/classes/MusicDescriptionShelf.js @@ -1,19 +1,23 @@ import Text from './misc/Text'; - import { YTNode } from '../helpers'; class MusicDescriptionShelf extends YTNode { static type = 'MusicDescriptionShelf'; + constructor(data) { super(); this.description = new Text(data.description); + if (this.max_collapsed_lines) { this.max_collapsed_lines = data.maxCollapsedLines; } + if (this.max_expanded_lines) { this.max_expanded_lines = data.maxExpandedLines; } + this.footer = new Text(data.footer); } } -export default MusicDescriptionShelf; + +export default MusicDescriptionShelf; \ No newline at end of file diff --git a/src/parser/classes/MusicDetailHeader.js b/src/parser/classes/MusicDetailHeader.js index af365acc..8d674866 100644 --- a/src/parser/classes/MusicDetailHeader.js +++ b/src/parser/classes/MusicDetailHeader.js @@ -1,11 +1,11 @@ import Text from './misc/Text'; import Thumbnail from './misc/Thumbnail'; import Parser from '../index'; - import { YTNode } from '../helpers'; class MusicDetailHeader extends YTNode { static type = 'MusicDetailHeader'; + constructor(data) { super(); this.title = new Text(data.title); @@ -17,7 +17,9 @@ class MusicDetailHeader extends YTNode { this.total_duration = this.second_subtitle.runs[2].text; this.thumbnails = Thumbnail.fromResponse(data.thumbnail.croppedSquareThumbnailRenderer.thumbnail); this.badges = Parser.parse(data.subtitleBadges); + const author = this.subtitle.runs.find((run) => run.endpoint.browse?.id.startsWith('UC')); + if (author) { this.author = { name: author.text, @@ -25,7 +27,9 @@ class MusicDetailHeader extends YTNode { endpoint: author.endpoint }; } + this.menu = Parser.parse(data.menu); } } -export default MusicDetailHeader; + +export default MusicDetailHeader; \ No newline at end of file diff --git a/src/parser/classes/MusicHeader.js b/src/parser/classes/MusicHeader.js index 63fc89ba..1ddcfa7b 100644 --- a/src/parser/classes/MusicHeader.js +++ b/src/parser/classes/MusicHeader.js @@ -1,12 +1,13 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class MusicHeader extends YTNode { static type = 'MusicHeader'; + constructor(data) { super(); this.header = Parser.parse(data.header); } } -export default MusicHeader; + +export default MusicHeader; \ No newline at end of file diff --git a/src/parser/classes/MusicImmersiveHeader.js b/src/parser/classes/MusicImmersiveHeader.js index 42dfe141..120bcac3 100644 --- a/src/parser/classes/MusicImmersiveHeader.js +++ b/src/parser/classes/MusicImmersiveHeader.js @@ -1,10 +1,10 @@ import Text from './misc/Text'; import Parser from '../index'; - import { YTNode } from '../helpers'; class MusicImmersiveHeader extends YTNode { static type = 'MusicImmersiveHeader'; + constructor(data) { super(); this.title = new Text(data.title); @@ -18,4 +18,5 @@ class MusicImmersiveHeader extends YTNode { */ } } -export default MusicImmersiveHeader; + +export default MusicImmersiveHeader; \ No newline at end of file diff --git a/src/parser/classes/MusicInlineBadge.js b/src/parser/classes/MusicInlineBadge.js index 27a8faf6..305f8aea 100644 --- a/src/parser/classes/MusicInlineBadge.js +++ b/src/parser/classes/MusicInlineBadge.js @@ -1,12 +1,13 @@ - import { YTNode } from '../helpers'; class MusicInlineBadge extends YTNode { static type = 'MusicInlineBadge'; + constructor(data) { super(); this.icon_type = data.icon.iconType; this.label = data.accessibilityData.accessibilityData.label; } } -export default MusicInlineBadge; + +export default MusicInlineBadge; \ No newline at end of file diff --git a/src/parser/classes/MusicItemThumbnailOverlay.js b/src/parser/classes/MusicItemThumbnailOverlay.js index 42b89961..4485d815 100644 --- a/src/parser/classes/MusicItemThumbnailOverlay.js +++ b/src/parser/classes/MusicItemThumbnailOverlay.js @@ -1,9 +1,9 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class MusicItemThumbnailOverlay extends YTNode { static type = 'MusicItemThumbnailOverlay'; + constructor(data) { super(); this.content = Parser.parse(data.content); @@ -11,4 +11,5 @@ class MusicItemThumbnailOverlay extends YTNode { this.display_style = data.displayStyle; } } -export default MusicItemThumbnailOverlay; + +export default MusicItemThumbnailOverlay; \ No newline at end of file diff --git a/src/parser/classes/MusicNavigationButton.js b/src/parser/classes/MusicNavigationButton.js index fa9d639b..345c00f1 100644 --- a/src/parser/classes/MusicNavigationButton.js +++ b/src/parser/classes/MusicNavigationButton.js @@ -1,14 +1,15 @@ import Text from './misc/Text'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class MusicNavigationButton extends YTNode { static type = 'MusicNavigationButton'; + constructor(data) { super(); this.button_text = new Text(data.buttonText).toString(); this.endpoint = new NavigationEndpoint(data.navigationEndpoint); } } -export default MusicNavigationButton; + +export default MusicNavigationButton; \ No newline at end of file diff --git a/src/parser/classes/MusicPlayButton.js b/src/parser/classes/MusicPlayButton.js index a0ae83d4..71200691 100644 --- a/src/parser/classes/MusicPlayButton.js +++ b/src/parser/classes/MusicPlayButton.js @@ -1,21 +1,25 @@ import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class MusicPlayButton extends YTNode { static type = 'MusicPlayButton'; + constructor(data) { super(); this.endpoint = new NavigationEndpoint(data.playNavigationEndpoint); this.play_icon_type = data.playIcon.iconType; this.pause_icon_type = data.pauseIcon.iconType; + if (data.accessibilityPlayData) { this.play_label = data.accessibilityPlayData.accessibilityData.label; } + if (data.accessibilityPlayData) { this.pause_label = data.accessibilityPauseData?.accessibilityData.label; } + this.icon_color = data.iconColor; } } -export default MusicPlayButton; + +export default MusicPlayButton; \ No newline at end of file diff --git a/src/parser/classes/MusicPlaylistShelf.js b/src/parser/classes/MusicPlaylistShelf.js index 42d936e6..92a2c7a4 100644 --- a/src/parser/classes/MusicPlaylistShelf.js +++ b/src/parser/classes/MusicPlaylistShelf.js @@ -1,10 +1,11 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class MusicPlaylistShelf extends YTNode { static type = 'MusicPlaylistShelf'; + #continuations; + constructor(data) { super(); this.playlist_id = data.playlistId; @@ -12,8 +13,10 @@ class MusicPlaylistShelf extends YTNode { this.collapsed_item_count = data.collapsedItemCount; this.#continuations = data.continuations; } + get continuation() { return this.#continuations?.[0]?.nextContinuationData; } } -export default MusicPlaylistShelf; + +export default MusicPlaylistShelf; \ No newline at end of file diff --git a/src/parser/classes/MusicQueue.js b/src/parser/classes/MusicQueue.js index 22e77f42..f4a75d5e 100644 --- a/src/parser/classes/MusicQueue.js +++ b/src/parser/classes/MusicQueue.js @@ -1,12 +1,13 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class MusicQueue extends YTNode { static type = 'MusicQueue'; + constructor(data) { super(); this.content = Parser.parse(data.content); } } -export default MusicQueue; + +export default MusicQueue; \ No newline at end of file diff --git a/src/parser/classes/MusicResponsiveListItem.ts b/src/parser/classes/MusicResponsiveListItem.ts index 082aa456..52a831d5 100644 --- a/src/parser/classes/MusicResponsiveListItem.ts +++ b/src/parser/classes/MusicResponsiveListItem.ts @@ -12,9 +12,11 @@ import TextRun from './misc/TextRun'; class MusicResponsiveListItem extends YTNode { static type = 'MusicResponsiveListItem'; + #flex_columns; #fixed_columns; #playlist_item_data; + endpoint; item_type; index; @@ -22,47 +24,56 @@ class MusicResponsiveListItem extends YTNode { badges; menu; overlay; + id?: string; title?: string; duration?: { - text: string; - seconds: number; - }; + text: string; + seconds: number; + }; + album?: { - id?: string, - name: string, - endpoint?: NavigationEndpoint - }; + id?: string, + name: string, + endpoint?: NavigationEndpoint + }; + artists?: { - name: string, - channel_id?: string, - endpoint?: NavigationEndpoint - }[]; + name: string, + channel_id?: string, + endpoint?: NavigationEndpoint + }[]; + views?: string; authors?: { - name: string, - channel_id?: string - endpoint?: NavigationEndpoint - }[]; + name: string, + channel_id?: string + endpoint?: NavigationEndpoint + }[]; + name?: string; subscribers?: string; // TODO: these might be replaceable with Author class author?: { - name: string, - channel_id?: string - endpoint?: NavigationEndpoint - }; + name: string, + channel_id?: string + endpoint?: NavigationEndpoint + }; item_count?: number; year?: string; + constructor(data: any) { super(); this.#flex_columns = Parser.parseArray(data.flexColumns); this.#fixed_columns = Parser.parseArray(data.fixedColumns); + this.#playlist_item_data = { video_id: data?.playlistItemData?.videoId || null, playlist_set_video_id: data?.playlistItemData?.playlistSetVideoId || null }; + this.endpoint = data.navigationEndpoint ? new NavigationEndpoint(data.navigationEndpoint) : undefined; + switch (this.endpoint?.browse?.page_type) { case 'MUSIC_PAGE_TYPE_ALBUM': this.item_type = 'album'; @@ -81,14 +92,17 @@ class MusicResponsiveListItem extends YTNode { this.#parseVideoOrSong(); break; } + if (data.index) { this.index = new Text(data.index); } + this.thumbnails = data.thumbnail ? Thumbnail.fromResponse(data.thumbnail.musicThumbnailRenderer.thumbnail) : []; this.badges = Parser.parseArray(data.badges); this.menu = Parser.parse(data.menu); this.overlay = Parser.parse(data.overlay); } + #parseVideoOrSong() { const is_video = this.#flex_columns[1].key('title').instanceof(Text).runs?.some((run) => run.text.match(/(.*?) views/)); if (is_video) { @@ -99,15 +113,20 @@ class MusicResponsiveListItem extends YTNode { this.#parseSong(); } } + #parseSong() { this.id = this.#playlist_item_data.video_id || this.endpoint?.watch?.video_id; this.title = this.#flex_columns[0].key('title').instanceof(Text).toString(); - const duration_text = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => (/^\d+$/).test(run.text.replace(/:/g, '')))?.text || - this.#fixed_columns?.[0]?.key('title').instanceof(Text)?.toString(); + + const duration_text = + this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => (/^\d+$/).test(run.text.replace(/:/g, '')))?.text || + this.#fixed_columns?.[0]?.key('title').instanceof(Text)?.toString(); + duration_text && (this.duration = { text: duration_text, seconds: timeToSeconds(duration_text) }); + const album = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('MPR')) as TextRun; if (album) { this.album = { @@ -116,6 +135,7 @@ class MusicResponsiveListItem extends YTNode { endpoint: album.endpoint }; } + const artists = this.#flex_columns[1].key('title').instanceof(Text).runs?.filter((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('UC')) as TextRun[]; if (artists) { this.artists = artists.map((artist) => ({ @@ -125,10 +145,12 @@ class MusicResponsiveListItem extends YTNode { })); } } + #parseVideo() { this.id = this.#playlist_item_data.video_id; this.title = this.#flex_columns[0].key('title').instanceof(Text).toString(); this.views = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => run.text.match(/(.*?) views/))?.text; + const authors = this.#flex_columns[1].key('title').instanceof(Text).runs?.filter((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('UC')) as TextRun[]; if (authors) { this.authors = authors.map((author) => ({ @@ -137,32 +159,39 @@ class MusicResponsiveListItem extends YTNode { endpoint: author.endpoint })); } + const duration_text = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => (/^\d+$/).test(run.text.replace(/:/g, '')))?.text; duration_text && (this.duration = { text: duration_text, seconds: timeToSeconds(duration_text) }); } + #parseArtist() { this.id = this.endpoint?.browse?.id; this.name = this.#flex_columns[0].key('title').instanceof(Text).toString(); this.subscribers = this.#flex_columns[1].key('title').instanceof(Text).runs?.[2]?.text || ''; } + #parseAlbum() { this.id = this.endpoint?.browse?.id; this.title = this.#flex_columns[0].key('title').instanceof(Text).toString(); + const author = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('UC')) as TextRun; author && (this.author = { name: author.text, channel_id: author.endpoint?.browse?.id, endpoint: author.endpoint }); + this.year = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => (/^[12][0-9]{3}$/).test(run.text))?.text; } + #parsePlaylist() { this.id = this.endpoint?.browse?.id; this.title = this.#flex_columns[0].key('title').instanceof(Text).toString(); this.item_count = parseInt(this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => run.text.match(/\d+ (song|songs)/))?.text.match(/\d+/g)); + const author = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('UC')) as TextRun; author && (this.author = { name: author.text, @@ -171,4 +200,5 @@ class MusicResponsiveListItem extends YTNode { }); } } -export default MusicResponsiveListItem; + +export default MusicResponsiveListItem; \ No newline at end of file diff --git a/src/parser/classes/MusicResponsiveListItemFixedColumn.js b/src/parser/classes/MusicResponsiveListItemFixedColumn.js index ab0d0ecb..1d3fe52b 100644 --- a/src/parser/classes/MusicResponsiveListItemFixedColumn.js +++ b/src/parser/classes/MusicResponsiveListItemFixedColumn.js @@ -1,13 +1,14 @@ import Text from './misc/Text'; - import { YTNode } from '../helpers'; class MusicResponsiveListItemFixedColumn extends YTNode { static type = 'musicResponsiveListItemFlexColumnRenderer'; + constructor(data) { super(); this.title = new Text(data.text); this.display_priority = data.displayPriority; } } -export default MusicResponsiveListItemFixedColumn; + +export default MusicResponsiveListItemFixedColumn; \ No newline at end of file diff --git a/src/parser/classes/MusicResponsiveListItemFlexColumn.js b/src/parser/classes/MusicResponsiveListItemFlexColumn.js index 343978ed..37c9f507 100644 --- a/src/parser/classes/MusicResponsiveListItemFlexColumn.js +++ b/src/parser/classes/MusicResponsiveListItemFlexColumn.js @@ -1,13 +1,14 @@ import Text from './misc/Text'; - import { YTNode } from '../helpers'; class MusicResponsiveListItemFlexColumn extends YTNode { static type = 'musicResponsiveListItemFlexColumnRenderer'; + constructor(data) { super(); this.title = new Text(data.text); this.display_priority = data.displayPriority; } } -export default MusicResponsiveListItemFlexColumn; + +export default MusicResponsiveListItemFlexColumn; \ No newline at end of file diff --git a/src/parser/classes/MusicShelf.js b/src/parser/classes/MusicShelf.js index dd4ec6aa..20c470bc 100644 --- a/src/parser/classes/MusicShelf.js +++ b/src/parser/classes/MusicShelf.js @@ -1,25 +1,29 @@ import Parser from '../index'; import Text from './misc/Text'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class MusicShelf extends YTNode { static type = 'MusicShelf'; + constructor(data) { super(); this.title = new Text(data.title).toString(); this.contents = Parser.parse(data.contents); + if (data.bottomEndpoint) { this.endpoint = new NavigationEndpoint(data.bottomEndpoint); } + if (data.continuations) { // TODO: type this this.continuation = data.continuations?.[0].nextContinuationData.continuation; } + if (data.bottomText) { this.bottom_text = new Text(data.bottomText); } } } -export default MusicShelf; + +export default MusicShelf; \ No newline at end of file diff --git a/src/parser/classes/MusicThumbnail.js b/src/parser/classes/MusicThumbnail.js index 7033170e..ecc4a7f9 100644 --- a/src/parser/classes/MusicThumbnail.js +++ b/src/parser/classes/MusicThumbnail.js @@ -1,12 +1,13 @@ import Thumbnail from './misc/Thumbnail'; - import { YTNode } from '../helpers'; class MusicThumbnail extends YTNode { static type = 'MusicThumbnail'; + constructor(data) { super(); return Thumbnail.fromResponse(data.thumbnail); } } -export default MusicThumbnail; + +export default MusicThumbnail; \ No newline at end of file diff --git a/src/parser/classes/MusicTwoRowItem.js b/src/parser/classes/MusicTwoRowItem.js index 316cb1ba..b66d7a56 100644 --- a/src/parser/classes/MusicTwoRowItem.js +++ b/src/parser/classes/MusicTwoRowItem.js @@ -2,20 +2,24 @@ import Parser from '../index'; import Text from './misc/Text'; import Thumbnail from './misc/Thumbnail'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class MusicTwoRowItem extends YTNode { static type = 'MusicTwoRowItem'; + constructor(data) { super(); + this.id = + this.endpoint.browse?.id || + this.endpoint.watch?.video_id; + this.title = new Text(data.title); this.endpoint = new NavigationEndpoint(data.navigationEndpoint); - this.id = this.endpoint.browse?.id || - this.endpoint.watch.video_id; + this.subtitle = new Text(data.subtitle); this.badges = Parser.parse(data.subtitleBadges); - switch (this.endpoint.browse?.page_type) { + + switch (this.endpoint?.browse?.page_type) { case 'MUSIC_PAGE_TYPE_ARTIST': this.type = 'artist'; this.subscribers = this.subtitle.toString(); @@ -43,13 +47,15 @@ class MusicTwoRowItem extends YTNode { break; default: if (this.subtitle.runs[0].text !== 'Song') { - this.type = ('video'); + this.type = 'video'; } else { - this.type = ('song'); + this.type = 'song'; } + if (this.type == 'video') { this.views = this.subtitle.runs .find((run) => run.text.match(/(.*?) views/)).text; + const author = this.subtitle.runs.find((run) => run.endpoint.browse?.id.startsWith('UC')); if (author) { this.author = { @@ -70,9 +76,11 @@ class MusicTwoRowItem extends YTNode { } break; } + this.thumbnail = Thumbnail.fromResponse(data.thumbnailRenderer.musicThumbnailRenderer.thumbnail); this.thumbnail_overlay = Parser.parse(data.thumbnailOverlay); this.menu = Parser.parse(data.menu); } } -export default MusicTwoRowItem; + +export default MusicTwoRowItem; \ No newline at end of file diff --git a/src/parser/classes/NavigationEndpoint.ts b/src/parser/classes/NavigationEndpoint.ts index ac09f964..7c69bd68 100644 --- a/src/parser/classes/NavigationEndpoint.ts +++ b/src/parser/classes/NavigationEndpoint.ts @@ -1,19 +1,22 @@ -import Parser, { ParsedResponse } from '../index'; - // TODO: refactor this import { YTNode } from '../helpers'; +import Parser, { ParsedResponse } from '../index'; import Actions, { ActionsResponse } from '../../core/Actions'; class NavigationEndpoint extends YTNode { + static type = 'NavigationEndpoint'; + payload; dialog; + metadata: { - url?: string; - api_url?: string; - page_type?: string; - send_post?: boolean; // TODO: is this boolean? - }; - // TODO: these should be given proper types, currently infered + url?: string; + api_url?: string; + page_type?: string; + send_post?: boolean; // TODO: is this boolean? + }; + + // TODO: these should be given proper types, currently infered browse; watch; search; @@ -30,31 +33,43 @@ class NavigationEndpoint extends YTNode { get_report_form; live_chat_item_context_menu; send_live_chat_vote; - static type = 'NavigationEndpoint'; + constructor(data: any) { super(); const name = Object.keys(data || {}) - .find((item) => item.endsWith('Endpoint') || item.endsWith('Command')); + .find((item) => + item.endsWith('Endpoint') || + item.endsWith('Command') + ); + this.payload = name ? Reflect.get(data, name) : {}; + if (Reflect.has(this.payload, 'dialog')) { this.dialog = Parser.parse(this.payload.dialog); } + if (data?.serviceEndpoint) { data = data.serviceEndpoint; } + this.metadata = {}; + if (data?.commandMetadata?.webCommandMetadata?.url) { this.metadata.url = data.commandMetadata.webCommandMetadata.url; } + if (data?.commandMetadata?.webCommandMetadata?.webPageType) { this.metadata.page_type = data.commandMetadata.webCommandMetadata.webPageType; } + if (data?.commandMetadata?.webCommandMetadata?.apiUrl) { this.metadata.api_url = data.commandMetadata.webCommandMetadata.apiUrl.replace('/youtubei/v1/', ''); } + if (data?.commandMetadata?.webCommandMetadata?.sendPost) { this.metadata.send_post = data.commandMetadata.webCommandMetadata.sendPost; } + if (data?.browseEndpoint) { const configs = data?.browseEndpoint?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig; this.browse = { @@ -64,6 +79,7 @@ class NavigationEndpoint extends YTNode { page_type: configs?.pageType || null }; } + if (data?.watchEndpoint) { const configs = data?.watchEndpoint?.watchEndpointMusicSupportedConfigs?.watchEndpointMusicConfig; this.watch = { @@ -75,24 +91,28 @@ class NavigationEndpoint extends YTNode { music_video_type: configs?.musicVideoType || null }; } + if (data?.searchEndpoint) { this.search = { query: data.searchEndpoint.query, params: data.searchEndpoint.params }; } + 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, @@ -100,16 +120,19 @@ class NavigationEndpoint extends YTNode { video_id: data.likeEndpoint.target.videoId, playlist_id: data.likeEndpoint.target.playlistId }, - params: data.likeEndpoint?.removeLikeParams || - data.likeEndpoint?.likeParams || - data.likeEndpoint?.dislikeParams + params: + data.likeEndpoint?.removeLikeParams || + data.likeEndpoint?.likeParams || + data.likeEndpoint?.dislikeParams }; } + if (data?.performCommentActionEndpoint) { this.perform_comment_action = { action: data?.performCommentActionEndpoint.action }; } + if (data?.offlineVideoEndpoint) { this.offline_video = { video_id: data.offlineVideoEndpoint.videoId, @@ -121,22 +144,26 @@ class NavigationEndpoint extends YTNode { } }; } + 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, @@ -146,37 +173,44 @@ class NavigationEndpoint extends YTNode { })) }; } + if (data?.addToPlaylistEndpoint) { this.add_to_playlist = { video_id: data.addToPlaylistEndpoint.videoId }; } + if (data?.addToPlaylistServiceEndpoint) { this.add_to_playlist = { video_id: data.addToPlaylistServiceEndpoint.videoId }; } + if (data?.getReportFormEndpoint) { this.get_report_form = { params: data.getReportFormEndpoint.params }; } + if (data?.liveChatItemContextMenuEndpoint) { this.live_chat_item_context_menu = { params: data?.liveChatItemContextMenuEndpoint?.params }; } + if (data?.sendLiveChatVoteEndpoint) { this.send_live_chat_vote = { params: data.sendLiveChatVoteEndpoint.params }; } + if (data?.liveChatItemContextMenuEndpoint) { this.live_chat_item_context_menu = { params: data.liveChatItemContextMenuEndpoint.params }; } } + /** * Calls the endpoint. (This is an experiment and may replace {@link call} in the future.). */ @@ -185,7 +219,9 @@ class NavigationEndpoint extends YTNode { throw new Error('An active caller must be provided'); if (!this.metadata.api_url) throw new Error('Expected an api_url, but none was found, this is a bug.'); + const response = await actions.execute(this.metadata.api_url, { ...this.payload, ...args.params, parse: args.parse }); + return response; } @@ -193,6 +229,7 @@ class NavigationEndpoint extends YTNode { async #call(actions: Actions, client?: string) { if (!actions) throw new Error('An active caller must be provided'); + if (this.continuation) { switch (this.continuation.request) { case 'CONTINUATION_REQUEST_TYPE_BROWSE': { @@ -208,12 +245,15 @@ class NavigationEndpoint extends YTNode { throw new Error(`${this.continuation.request} not implemented`); } } + if (this.search) { return await actions.search({ query: this.search.query, params: this.search.params, client }); } + if (this.browse) { return await actions.browse(this.browse.id, { ...this.browse, client }); } + if (this.like) { if (!this.metadata.api_url) throw new Error('Like endpoint requires an api_url, but was not parsed from the response.'); @@ -226,9 +266,12 @@ class NavigationEndpoint extends YTNode { async call(actions: Actions, client?: string, parse?: false) : Promise; async call(actions: Actions, client?: string, parse?: boolean): Promise { const result = await this.#call(actions, client); + if (parse && result) return Parser.parseResponse(result.data); + return this.#call(actions, client); } } -export default NavigationEndpoint; + +export default NavigationEndpoint; \ No newline at end of file diff --git a/src/parser/classes/Notification.js b/src/parser/classes/Notification.js index 37e77527..b7da28af 100644 --- a/src/parser/classes/Notification.js +++ b/src/parser/classes/Notification.js @@ -2,11 +2,11 @@ import Parser from '../index'; import Text from './misc/Text'; import Thumbnail from './misc/Thumbnail'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class Notification extends YTNode { static type = 'Notification'; + constructor(data) { super(); this.thumbnails = Thumbnail.fromResponse(data.thumbnail); @@ -20,4 +20,5 @@ class Notification extends YTNode { this.read = data.read; } } -export default Notification; + +export default Notification; \ No newline at end of file diff --git a/src/parser/classes/PlayerAnnotationsExpanded.js b/src/parser/classes/PlayerAnnotationsExpanded.js index b4d8bcf1..c9a7c31c 100644 --- a/src/parser/classes/PlayerAnnotationsExpanded.js +++ b/src/parser/classes/PlayerAnnotationsExpanded.js @@ -1,13 +1,14 @@ import Parser from '../index'; import Thumbnail from './misc/Thumbnail'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class PlayerAnnotationsExpanded extends YTNode { static type = 'PlayerAnnotationsExpanded'; + constructor(data) { super(); + this.featured_channel = { start_time_ms: data.featuredChannel.startTimeMs, end_time_ms: data.featuredChannel.endTimeMs, @@ -16,8 +17,10 @@ class PlayerAnnotationsExpanded extends YTNode { endpoint: new NavigationEndpoint(data.featuredChannel.navigationEndpoint), subscribe_button: Parser.parse(data.featuredChannel.subscribeButton) }; + this.allow_swipe_dismiss = data.allowSwipeDismiss; this.annotation_id = data.annotationId; } } -export default PlayerAnnotationsExpanded; + +export default PlayerAnnotationsExpanded; \ No newline at end of file diff --git a/src/parser/classes/PlayerCaptionsTracklist.js b/src/parser/classes/PlayerCaptionsTracklist.js index a45905cd..a7efa1ef 100644 --- a/src/parser/classes/PlayerCaptionsTracklist.js +++ b/src/parser/classes/PlayerCaptionsTracklist.js @@ -1,9 +1,9 @@ import Text from './misc/Text'; - import { YTNode } from '../helpers'; class PlayerCaptionsTracklist extends YTNode { static type = 'PlayerCaptionsTracklist'; + constructor(data) { super(); this.caption_tracks = data.captionTracks.map((ct) => ({ @@ -14,13 +14,16 @@ class PlayerCaptionsTracklist extends YTNode { kind: ct.kind, is_translatable: ct.isTranslatable })); + this.audio_tracks = data.audioTracks.map((at) => ({ caption_track_indices: at.captionTrackIndices })); + this.translation_languages = data.translationLanguages.map((tl) => ({ language_code: tl.languageCode, language_name: new Text(tl.languageName) })); } } -export default PlayerCaptionsTracklist; + +export default PlayerCaptionsTracklist; \ No newline at end of file diff --git a/src/parser/classes/PlayerErrorMessage.js b/src/parser/classes/PlayerErrorMessage.js index c4bc80f9..0f2f456f 100644 --- a/src/parser/classes/PlayerErrorMessage.js +++ b/src/parser/classes/PlayerErrorMessage.js @@ -1,11 +1,11 @@ import Parser from '../index'; import Text from './misc/Text'; import Thumbnail from './misc/Thumbnail'; - import { YTNode } from '../helpers'; class PlayerErrorMessage extends YTNode { static type = 'PlayerErrorMessage'; + constructor(data) { super(); this.subreason = new Text(data.subreason); @@ -15,4 +15,5 @@ class PlayerErrorMessage extends YTNode { this.icon_type = data.icon.iconType; } } -export default PlayerErrorMessage; + +export default PlayerErrorMessage; \ No newline at end of file diff --git a/src/parser/classes/PlayerLiveStoryboardSpec.js b/src/parser/classes/PlayerLiveStoryboardSpec.js index ee919fdc..69b57154 100644 --- a/src/parser/classes/PlayerLiveStoryboardSpec.js +++ b/src/parser/classes/PlayerLiveStoryboardSpec.js @@ -1,11 +1,13 @@ - import { YTNode } from '../helpers'; + class PlayerLiveStoryboardSpec extends YTNode { static type = 'PlayerLiveStoryboardSpec'; + constructor() { super(); // TODO: A little bit different from PlayerLiveStoryboardSpec // https://i.ytimg.com/sb/5qap5aO4i9A/storyboard_live_90_2x2_b2/M$M.jpg?rs=AOn4CLC9s6IeOsw_gKvEbsbU9y-e2FVRTw#159#90#2#2 } } -export default PlayerLiveStoryboardSpec; + +export default PlayerLiveStoryboardSpec; \ No newline at end of file diff --git a/src/parser/classes/PlayerMicroformat.js b/src/parser/classes/PlayerMicroformat.js index 243fc555..2737a82d 100644 --- a/src/parser/classes/PlayerMicroformat.js +++ b/src/parser/classes/PlayerMicroformat.js @@ -1,15 +1,16 @@ import Text from './misc/Text'; import Thumbnail from './misc/Thumbnail'; - import { YTNode } from '../helpers'; class PlayerMicroformat extends YTNode { static type = 'PlayerMicroformat'; + constructor(data) { super(); this.title = new Text(data.title); this.description = new Text(data.description); this.thumbnails = Thumbnail.fromResponse(data.thumbnail); + this.embed = { iframe_url: data.embed.iframeUrl, flash_url: data.embed.flashUrl, @@ -17,12 +18,15 @@ class PlayerMicroformat extends YTNode { 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; @@ -33,4 +37,5 @@ class PlayerMicroformat extends YTNode { this.available_countries = data.availableCountries; } } -export default PlayerMicroformat; + +export default PlayerMicroformat; \ No newline at end of file diff --git a/src/parser/classes/PlayerOverlay.js b/src/parser/classes/PlayerOverlay.js index 3f284fc0..c39a9931 100644 --- a/src/parser/classes/PlayerOverlay.js +++ b/src/parser/classes/PlayerOverlay.js @@ -1,9 +1,9 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class PlayerOverlay extends YTNode { static type = 'PlayerOverlay'; + constructor(data) { super(); this.end_screen = Parser.parse(data.endScreen); @@ -13,4 +13,5 @@ class PlayerOverlay extends YTNode { this.fullscreen_engagement = Parser.parse(data.fullscreenEngagement); } } -export default PlayerOverlay; + +export default PlayerOverlay; \ No newline at end of file diff --git a/src/parser/classes/PlayerOverlayAutoplay.js b/src/parser/classes/PlayerOverlayAutoplay.js index e0e16949..3935c107 100644 --- a/src/parser/classes/PlayerOverlayAutoplay.js +++ b/src/parser/classes/PlayerOverlayAutoplay.js @@ -2,11 +2,11 @@ import Parser from '../index'; import Text from './misc/Text'; import Author from './misc/Author'; import Thumbnail from './misc/Thumbnail'; - import { YTNode } from '../helpers'; class PlayerOverlayAutoplay extends YTNode { static type = 'PlayerOverlayAutoplay'; + constructor(data) { super(); this.title = new Text(data.title); @@ -24,4 +24,5 @@ class PlayerOverlayAutoplay extends YTNode { this.close_button = Parser.parse(data.closeButton); } } -export default PlayerOverlayAutoplay; + +export default PlayerOverlayAutoplay; \ No newline at end of file diff --git a/src/parser/classes/PlayerStoryboardSpec.js b/src/parser/classes/PlayerStoryboardSpec.js index 29841084..c3c674f0 100644 --- a/src/parser/classes/PlayerStoryboardSpec.js +++ b/src/parser/classes/PlayerStoryboardSpec.js @@ -1,19 +1,25 @@ - import { YTNode } from '../helpers'; class PlayerStoryboardSpec extends YTNode { static type = 'PlayerStoryboardSpec'; + constructor(data) { super(); + 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), @@ -27,4 +33,5 @@ class PlayerStoryboardSpec extends YTNode { }); } } -export default PlayerStoryboardSpec; + +export default PlayerStoryboardSpec; \ No newline at end of file diff --git a/src/parser/classes/Playlist.js b/src/parser/classes/Playlist.js index b193400d..78ade20d 100644 --- a/src/parser/classes/Playlist.js +++ b/src/parser/classes/Playlist.js @@ -3,18 +3,20 @@ import Parser from '../index'; import Thumbnail from './misc/Thumbnail'; import NavigationEndpoint from './NavigationEndpoint'; import PlaylistAuthor from './misc/PlaylistAuthor'; - import { YTNode } from '../helpers'; class Playlist extends YTNode { static type = 'Playlist'; + constructor(data) { super(); this.id = data.playlistId; this.title = new Text(data.title); + this.author = data.shortBylineText?.simpleText ? new Text(data.shortBylineText) : new PlaylistAuthor(data.longBylineText, data.ownerBadges, null); + this.thumbnails = Thumbnail.fromResponse(data.thumbnail || { thumbnails: data.thumbnails.map((th) => th.thumbnails).flat(1) }); this.video_count = new Text(data.thumbnailText); this.video_count_short = new Text(data.videoCountShortText); @@ -26,4 +28,5 @@ class Playlist extends YTNode { this.thumbnail_overlays = Parser.parse(data.thumbnailOverlays) || []; } } -export default Playlist; + +export default Playlist; \ No newline at end of file diff --git a/src/parser/classes/PlaylistHeader.js b/src/parser/classes/PlaylistHeader.js index e050d628..9cb40092 100644 --- a/src/parser/classes/PlaylistHeader.js +++ b/src/parser/classes/PlaylistHeader.js @@ -1,11 +1,11 @@ import Text from './misc/Text'; import PlaylistAuthor from './misc/PlaylistAuthor'; import Parser from '../index'; - import { YTNode } from '../helpers'; class PlaylistHeader extends YTNode { static type = 'PlaylistHeader'; + constructor(data) { super(); this.id = data.playlistId; @@ -25,4 +25,5 @@ class PlaylistHeader extends YTNode { this.menu = Parser.parse(data.moreActionsMenu); } } -export default PlaylistHeader; + +export default PlaylistHeader; \ No newline at end of file diff --git a/src/parser/classes/PlaylistInfoCardContent.js b/src/parser/classes/PlaylistInfoCardContent.js index b5a625c5..61140265 100644 --- a/src/parser/classes/PlaylistInfoCardContent.js +++ b/src/parser/classes/PlaylistInfoCardContent.js @@ -1,11 +1,11 @@ import Text from './misc/Text'; import Thumbnail from './misc/Thumbnail'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class PlaylistInfoCardContent extends YTNode { static type = 'PlaylistInfoCardContent'; + constructor(data) { super(); this.title = new Text(data.playlistTitle); @@ -15,4 +15,5 @@ class PlaylistInfoCardContent extends YTNode { this.endpoint = new NavigationEndpoint(data.action); } } -export default PlaylistInfoCardContent; + +export default PlaylistInfoCardContent; \ No newline at end of file diff --git a/src/parser/classes/PlaylistMetadata.js b/src/parser/classes/PlaylistMetadata.js index 4e251460..8efe09a9 100644 --- a/src/parser/classes/PlaylistMetadata.js +++ b/src/parser/classes/PlaylistMetadata.js @@ -1,8 +1,8 @@ - import { YTNode } from '../helpers'; class PlaylistMetadata extends YTNode { static type = 'PlaylistMetadata'; + constructor(data) { super(); this.title = data.title; @@ -10,4 +10,5 @@ class PlaylistMetadata extends YTNode { // XXX: Appindexing should be in microformat } } -export default PlaylistMetadata; + +export default PlaylistMetadata; \ No newline at end of file diff --git a/src/parser/classes/PlaylistPanel.js b/src/parser/classes/PlaylistPanel.js index f05fe78d..82ea6f93 100644 --- a/src/parser/classes/PlaylistPanel.js +++ b/src/parser/classes/PlaylistPanel.js @@ -1,10 +1,10 @@ import Parser from '../index'; import Text from './misc/Text'; - import { YTNode } from '../helpers'; class PlaylistPanel extends YTNode { static type = 'PlaylistPanel'; + constructor(data) { super(); this.title = data.title; @@ -18,4 +18,5 @@ class PlaylistPanel extends YTNode { this.num_items_to_show = data.numItemsToShow; } } -export default PlaylistPanel; + +export default PlaylistPanel; \ No newline at end of file diff --git a/src/parser/classes/PlaylistPanelVideo.js b/src/parser/classes/PlaylistPanelVideo.js index ec287dd6..af366a90 100644 --- a/src/parser/classes/PlaylistPanelVideo.js +++ b/src/parser/classes/PlaylistPanelVideo.js @@ -3,11 +3,11 @@ import Text from './misc/Text'; import Thumbnail from './misc/Thumbnail'; import NavigationEndpoint from './NavigationEndpoint'; import { timeToSeconds } from '../../utils/Utils'; - import { YTNode } from '../helpers'; class PlaylistPanelVideo extends YTNode { static type = 'PlaylistPanelVideo'; + constructor(data) { super(); this.title = new Text(data.title); @@ -15,27 +15,36 @@ class PlaylistPanelVideo extends YTNode { this.endpoint = new NavigationEndpoint(data.navigationEndpoint); this.selected = data.selected; this.video_id = data.videoId; + this.duration = { text: new Text(data.lengthText).toString(), seconds: timeToSeconds(new Text(data.lengthText).toString()) }; + const album = new Text(data.longBylineText).runs.find((run) => run.endpoint.browse?.id.startsWith('MPR')); const artists = new Text(data.longBylineText).runs.filter((run) => run.endpoint.browse?.id.startsWith('UC')); + this.author = new Text(data.shortBylineText).toString(); - album && (this.album = { - id: album.endpoint.browse.id, - name: album.text, - year: new Text(data.longBylineText).runs.slice(-1)[0].text, - endpoint: album.endpoint - }); + + if (album) { + this.album = { + id: album.endpoint.browse.id, + name: album.text, + year: new Text(data.longBylineText).runs.slice(-1)[0].text, + endpoint: album.endpoint + }; + } + this.artists = artists.map((artist) => ({ name: artist.text, channel_id: artist.endpoint.browse.id, endpoint: artist.endpoint })); + this.badges = Parser.parse(data.badges); this.menu = Parser.parse(data.menu); this.set_video_id = data.playlistSetVideoId; } } -export default PlaylistPanelVideo; + +export default PlaylistPanelVideo; \ No newline at end of file diff --git a/src/parser/classes/PlaylistSidebar.js b/src/parser/classes/PlaylistSidebar.js index 40f497b3..49a335ce 100644 --- a/src/parser/classes/PlaylistSidebar.js +++ b/src/parser/classes/PlaylistSidebar.js @@ -1,16 +1,18 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class PlaylistSidebar extends YTNode { static type = 'PlaylistSidebar'; + constructor(data) { super(); this.items = Parser.parse(data.items); } + // XXX: alias for consistency get contents() { return this.items; } } -export default PlaylistSidebar; + +export default PlaylistSidebar; \ No newline at end of file diff --git a/src/parser/classes/PlaylistSidebarPrimaryInfo.js b/src/parser/classes/PlaylistSidebarPrimaryInfo.js index 06d05386..86760bf6 100644 --- a/src/parser/classes/PlaylistSidebarPrimaryInfo.js +++ b/src/parser/classes/PlaylistSidebarPrimaryInfo.js @@ -1,11 +1,11 @@ import Parser from '../index'; import NavigationEndpoint from './NavigationEndpoint'; import Text from './misc/Text'; - import { YTNode } from '../helpers'; class PlaylistSidebarPrimaryInfo extends YTNode { static type = 'PlaylistSidebarPrimaryInfo'; + constructor(data) { super(); this.stats = data.stats.map((stat) => new Text(stat)); @@ -16,4 +16,5 @@ class PlaylistSidebarPrimaryInfo extends YTNode { this.description = new Text(data.description); } } -export default PlaylistSidebarPrimaryInfo; + +export default PlaylistSidebarPrimaryInfo; \ No newline at end of file diff --git a/src/parser/classes/PlaylistSidebarSecondaryInfo.js b/src/parser/classes/PlaylistSidebarSecondaryInfo.js index 5b1b9914..d7d709c6 100644 --- a/src/parser/classes/PlaylistSidebarSecondaryInfo.js +++ b/src/parser/classes/PlaylistSidebarSecondaryInfo.js @@ -1,13 +1,14 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class PlaylistSidebarSecondaryInfo extends YTNode { static type = 'PlaylistSidebarSecondaryInfo'; + constructor(data) { super(); this.owner = Parser.parse(data.videoOwner) || null; this.button = Parser.parse(data.button) || null; } } -export default PlaylistSidebarSecondaryInfo; + +export default PlaylistSidebarSecondaryInfo; \ No newline at end of file diff --git a/src/parser/classes/PlaylistVideo.js b/src/parser/classes/PlaylistVideo.js index 5a0ae3f4..8664ab5f 100644 --- a/src/parser/classes/PlaylistVideo.js +++ b/src/parser/classes/PlaylistVideo.js @@ -3,11 +3,11 @@ import Parser from '../index'; import Thumbnail from './misc/Thumbnail'; import PlaylistAuthor from './misc/PlaylistAuthor'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class PlaylistVideo extends YTNode { static type = 'PlaylistVideo'; + constructor(data) { super(); this.id = data.videoId; @@ -26,4 +26,5 @@ class PlaylistVideo extends YTNode { }; } } -export default PlaylistVideo; + +export default PlaylistVideo; \ No newline at end of file diff --git a/src/parser/classes/PlaylistVideoList.js b/src/parser/classes/PlaylistVideoList.js index 5392392f..16450b7e 100644 --- a/src/parser/classes/PlaylistVideoList.js +++ b/src/parser/classes/PlaylistVideoList.js @@ -1,9 +1,9 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class PlaylistVideoList extends YTNode { static type = 'PlaylistVideoList'; + constructor(data) { super(); this.id = data.playlistId; @@ -12,4 +12,5 @@ class PlaylistVideoList extends YTNode { this.videos = Parser.parse(data.contents); } } -export default PlaylistVideoList; + +export default PlaylistVideoList; \ No newline at end of file diff --git a/src/parser/classes/PlaylistVideoThumbnail.js b/src/parser/classes/PlaylistVideoThumbnail.js index 9cebfa41..e360ca9f 100644 --- a/src/parser/classes/PlaylistVideoThumbnail.js +++ b/src/parser/classes/PlaylistVideoThumbnail.js @@ -1,12 +1,13 @@ import Thumbnail from './misc/Thumbnail'; - import { YTNode } from '../helpers'; class PlaylistVideoThumbnail extends YTNode { static type = 'PlaylistVideoThumbnail'; + constructor(data) { super(); this.thumbnail = Thumbnail.fromResponse(data.thumbnail); } } -export default PlaylistVideoThumbnail; + +export default PlaylistVideoThumbnail; \ No newline at end of file diff --git a/src/parser/classes/Poll.js b/src/parser/classes/Poll.js index 58d97877..20c531fa 100644 --- a/src/parser/classes/Poll.js +++ b/src/parser/classes/Poll.js @@ -1,13 +1,14 @@ import Text from './misc/Text'; import Thumbnail from './misc/Thumbnail'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class Poll extends YTNode { static type = 'Poll'; + constructor(data) { super(); + this.choices = data.choices.map((choice) => ({ text: new Text(choice.text).toString(), select_endpoint: choice.selectServiceEndpoint ? new NavigationEndpoint(choice.selectServiceEndpoint) : null, @@ -18,12 +19,16 @@ class Poll extends YTNode { vote_percentage_if_not_selected: new Text(choice.votePercentageIfSelected), image: choice.image ? Thumbnail.fromResponse(choice.image) : null })); + if (data.type) this.poll_type = data.type; + if (data.totalVotes) this.total_votes = new Text(data.totalVotes); + if (data.liveChatPollId) this.live_chat_poll_id = data.liveChatPollId; } } -export default Poll; + +export default Poll; \ No newline at end of file diff --git a/src/parser/classes/Post.js b/src/parser/classes/Post.js index 0c62ed05..fefdd77f 100644 --- a/src/parser/classes/Post.js +++ b/src/parser/classes/Post.js @@ -2,8 +2,10 @@ import BackstagePost from './BackstagePost'; class Post extends BackstagePost { static type = 'Post'; + constructor(data) { super(data); } } -export default Post; + +export default Post; \ No newline at end of file diff --git a/src/parser/classes/ProfileColumn.js b/src/parser/classes/ProfileColumn.js index c4b9c6bd..3d73d1e4 100644 --- a/src/parser/classes/ProfileColumn.js +++ b/src/parser/classes/ProfileColumn.js @@ -1,16 +1,18 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class ProfileColumn extends YTNode { static type = 'ProfileColumn'; + constructor(data) { super(); this.items = Parser.parse(data.items); } + // XXX: alias for consistency get contents() { return this.items; } } -export default ProfileColumn; + +export default ProfileColumn; \ No newline at end of file diff --git a/src/parser/classes/ProfileColumnStats.js b/src/parser/classes/ProfileColumnStats.js index ea8a6fb1..c08050ad 100644 --- a/src/parser/classes/ProfileColumnStats.js +++ b/src/parser/classes/ProfileColumnStats.js @@ -1,16 +1,18 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class ProfileColumnStats extends YTNode { static type = 'ProfileColumnStats'; + constructor(data) { super(); this.items = Parser.parse(data.items); } + // XXX: alias for consistency get contents() { return this.items; } } -export default ProfileColumnStats; + +export default ProfileColumnStats; \ No newline at end of file diff --git a/src/parser/classes/ProfileColumnStatsEntry.js b/src/parser/classes/ProfileColumnStatsEntry.js index 406d867b..b73e738a 100644 --- a/src/parser/classes/ProfileColumnStatsEntry.js +++ b/src/parser/classes/ProfileColumnStatsEntry.js @@ -1,13 +1,14 @@ import Text from './misc/Text'; - import { YTNode } from '../helpers'; class ProfileColumnStatsEntry extends YTNode { static type = 'ProfileColumnStatsEntry'; + constructor(data) { super(); this.label = new Text(data.label); this.value = new Text(data.value); } } -export default ProfileColumnStatsEntry; + +export default ProfileColumnStatsEntry; \ No newline at end of file diff --git a/src/parser/classes/ProfileColumnUserInfo.js b/src/parser/classes/ProfileColumnUserInfo.js index 4a8258c2..ecdddba1 100644 --- a/src/parser/classes/ProfileColumnUserInfo.js +++ b/src/parser/classes/ProfileColumnUserInfo.js @@ -1,14 +1,15 @@ import Text from './misc/Text'; import Thumbnail from './misc/Thumbnail'; - import { YTNode } from '../helpers'; class ProfileColumnUserInfo extends YTNode { static type = 'ProfileColumnUserInfo'; + constructor(data) { super(); this.title = new Text(data.title); this.thumbnails = Thumbnail.fromResponse(data.thumbnail); } } -export default ProfileColumnUserInfo; + +export default ProfileColumnUserInfo; \ No newline at end of file diff --git a/src/parser/classes/ReelItem.js b/src/parser/classes/ReelItem.js index 91d9bcd4..534861af 100644 --- a/src/parser/classes/ReelItem.js +++ b/src/parser/classes/ReelItem.js @@ -1,11 +1,11 @@ import NavigationEndpoint from './NavigationEndpoint'; import Text from './misc/Text'; import Thumbnail from './misc/Thumbnail'; - import { YTNode } from '../helpers'; class ReelItem extends YTNode { static type = 'ReelItem'; + constructor(data) { super(); this.id = data.videoId; @@ -15,4 +15,5 @@ class ReelItem extends YTNode { this.endpoint = new NavigationEndpoint(data.navigationEndpoint); } } -export default ReelItem; + +export default ReelItem; \ No newline at end of file diff --git a/src/parser/classes/ReelShelf.js b/src/parser/classes/ReelShelf.js index f8528ef8..825fad5f 100644 --- a/src/parser/classes/ReelShelf.js +++ b/src/parser/classes/ReelShelf.js @@ -1,20 +1,22 @@ import Parser from '../index'; import NavigationEndpoint from './NavigationEndpoint'; import Text from './misc/Text'; - import { YTNode } from '../helpers'; class ReelShelf extends YTNode { static type = 'ReelShelf'; + constructor(data) { super(); this.title = new Text(data.title); this.items = Parser.parse(data.items); this.endpoint = data.endpoint ? new NavigationEndpoint(data.endpoint) : null; } + // XXX: alias for consistency get contents() { return this.items; } } -export default ReelShelf; + +export default ReelShelf; \ No newline at end of file diff --git a/src/parser/classes/RelatedChipCloud.js b/src/parser/classes/RelatedChipCloud.js index b85a101e..a1ac784c 100644 --- a/src/parser/classes/RelatedChipCloud.js +++ b/src/parser/classes/RelatedChipCloud.js @@ -1,12 +1,13 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class RelatedChipCloud extends YTNode { static type = 'RelatedChipCloud'; + constructor(data) { super(); this.content = Parser.parse(data.content); } } -export default RelatedChipCloud; + +export default RelatedChipCloud; \ No newline at end of file diff --git a/src/parser/classes/RichItem.js b/src/parser/classes/RichItem.js index 49433b29..f66f47e2 100644 --- a/src/parser/classes/RichItem.js +++ b/src/parser/classes/RichItem.js @@ -1,12 +1,13 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class RichItem extends YTNode { static type = 'RichItem'; + constructor(data) { super(); return Parser.parse(data.content); } } -export default RichItem; + +export default RichItem; \ No newline at end of file diff --git a/src/parser/classes/RichListHeader.js b/src/parser/classes/RichListHeader.js index f1655f01..d17a04b4 100644 --- a/src/parser/classes/RichListHeader.js +++ b/src/parser/classes/RichListHeader.js @@ -1,13 +1,14 @@ import Text from './misc/Text'; - import { YTNode } from '../helpers'; class RichListHeader extends YTNode { static type = 'RichListHeader'; + constructor(data) { super(); this.title = new Text(data.title); this.icon_type = data.icon.iconType; } } -export default RichListHeader; + +export default RichListHeader; \ No newline at end of file diff --git a/src/parser/classes/RichSection.js b/src/parser/classes/RichSection.js index 5d664fac..81a2eb0c 100644 --- a/src/parser/classes/RichSection.js +++ b/src/parser/classes/RichSection.js @@ -1,12 +1,13 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class RichSection extends YTNode { static type = 'RichSection'; + constructor(data) { super(); this.contents = Parser.parse(data.content); } } -export default RichSection; + +export default RichSection; \ No newline at end of file diff --git a/src/parser/classes/RichShelf.js b/src/parser/classes/RichShelf.js index 7318aa20..bca7ea33 100644 --- a/src/parser/classes/RichShelf.js +++ b/src/parser/classes/RichShelf.js @@ -1,11 +1,11 @@ import Parser from '../index'; import NavigationEndpoint from './NavigationEndpoint'; import Text from './misc/Text'; - import { YTNode } from '../helpers'; class RichShelf extends YTNode { static type = 'RichShelf'; + constructor(data) { super(); this.title = new Text(data.title); @@ -13,4 +13,5 @@ class RichShelf extends YTNode { this.endpoint = data.endpoint ? new NavigationEndpoint(data.endpoint) : null; } } -export default RichShelf; + +export default RichShelf; \ No newline at end of file diff --git a/src/parser/classes/SearchBox.js b/src/parser/classes/SearchBox.js index b9106802..220d8c98 100644 --- a/src/parser/classes/SearchBox.js +++ b/src/parser/classes/SearchBox.js @@ -1,11 +1,11 @@ import Parser from '../index'; import Text from './misc/Text'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class SearchBox extends YTNode { static type = 'SearchBox'; + constructor(data) { super(); this.endpoint = new NavigationEndpoint(data.endpoint); @@ -14,4 +14,5 @@ class SearchBox extends YTNode { this.placeholder_text = new Text(data.placeholderText); } } -export default SearchBox; + +export default SearchBox; \ No newline at end of file diff --git a/src/parser/classes/SearchRefinementCard.js b/src/parser/classes/SearchRefinementCard.js index 3b463d00..a391814c 100644 --- a/src/parser/classes/SearchRefinementCard.js +++ b/src/parser/classes/SearchRefinementCard.js @@ -1,11 +1,11 @@ import NavigationEndpoint from './NavigationEndpoint'; import Thumbnail from './misc/Thumbnail'; import Text from './misc/Text'; - import { YTNode } from '../helpers'; class SearchRefinementCard extends YTNode { static type = 'SearchRefinementCard'; + constructor(data) { super(); this.thumbnails = Thumbnail.fromResponse(data.thumbnail); @@ -13,4 +13,5 @@ class SearchRefinementCard extends YTNode { this.query = new Text(data.query).toString(); } } -export default SearchRefinementCard; + +export default SearchRefinementCard; \ No newline at end of file diff --git a/src/parser/classes/SearchSuggestion.js b/src/parser/classes/SearchSuggestion.js index 4cccda30..14fcf1b4 100644 --- a/src/parser/classes/SearchSuggestion.js +++ b/src/parser/classes/SearchSuggestion.js @@ -1,18 +1,20 @@ import Text from './misc/Text'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class SearchSuggestion extends YTNode { static type = 'SearchSuggestion'; + constructor(data) { super(); this.suggestion = new Text(data.suggestion); this.endpoint = new NavigationEndpoint(data.navigationEndpoint); this.icon_type = data.icon.iconType; + if (data.serviceEndpoint) { this.service_endpoint = new NavigationEndpoint(data.serviceEndpoint); } } } -export default SearchSuggestion; + +export default SearchSuggestion; \ No newline at end of file diff --git a/src/parser/classes/SearchSuggestionsSection.js b/src/parser/classes/SearchSuggestionsSection.js index 8eb5ba34..eb309987 100644 --- a/src/parser/classes/SearchSuggestionsSection.js +++ b/src/parser/classes/SearchSuggestionsSection.js @@ -1,12 +1,13 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class SearchSuggestionsSection extends YTNode { static type = 'SearchSuggestionsSection'; + constructor(data) { super(); this.contents = Parser.parse(data.contents); } } -export default SearchSuggestionsSection; + +export default SearchSuggestionsSection; \ No newline at end of file diff --git a/src/parser/classes/SecondarySearchContainer.js b/src/parser/classes/SecondarySearchContainer.js index 37b4a0fe..45085024 100644 --- a/src/parser/classes/SecondarySearchContainer.js +++ b/src/parser/classes/SecondarySearchContainer.js @@ -1,12 +1,13 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class SecondarySearchContainer extends YTNode { static type = 'SecondarySearchContainer'; + constructor(data) { super(); this.contents = Parser.parse(data.contents); } } -export default SecondarySearchContainer; + +export default SecondarySearchContainer; \ No newline at end of file diff --git a/src/parser/classes/SectionList.js b/src/parser/classes/SectionList.js index 6b9d0c2e..a202f153 100644 --- a/src/parser/classes/SectionList.js +++ b/src/parser/classes/SectionList.js @@ -1,15 +1,17 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class SectionList extends YTNode { static type = 'SectionList'; + constructor(data) { super(); if (data.targetId) { this.target_id = data.targetId; } + this.contents = Parser.parse(data.contents); + if (data.continuations) { if (data.continuations[0].nextContinuationData) { this.continuation = data.continuations[0].nextContinuationData.continuation; @@ -17,9 +19,11 @@ class SectionList extends YTNode { this.continuation = data.continuations[0].reloadContinuationData.continuation; } } + if (data.header) { this.header = Parser.parse(data.header); } } } -export default SectionList; + +export default SectionList; \ No newline at end of file diff --git a/src/parser/classes/Shelf.js b/src/parser/classes/Shelf.js index fe63ea81..7a21af30 100644 --- a/src/parser/classes/Shelf.js +++ b/src/parser/classes/Shelf.js @@ -1,24 +1,29 @@ import Text from './misc/Text'; import Parser from '../index'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class Shelf extends YTNode { static type = 'Shelf'; + constructor(data) { super(); this.title = new Text(data.title); + if (data.endpoint) { this.endpoint = new NavigationEndpoint(data.endpoint); } + this.content = Parser.parse(data.content) || []; + if (data.icon?.iconType) { this.icon_type = data.icon?.iconType; } + if (data.menu) { this.menu = Parser.parse(data.menu); } } } -export default Shelf; + +export default Shelf; \ No newline at end of file diff --git a/src/parser/classes/ShowingResultsFor.js b/src/parser/classes/ShowingResultsFor.js index 1a1fec7f..47931b99 100644 --- a/src/parser/classes/ShowingResultsFor.js +++ b/src/parser/classes/ShowingResultsFor.js @@ -1,10 +1,10 @@ import Text from './misc/Text'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class ShowingResultsFor extends YTNode { static type = 'ShowingResultsFor'; + constructor(data) { super(); this.corrected_query = new Text(data.correctedQuery); @@ -12,4 +12,5 @@ class ShowingResultsFor extends YTNode { this.original_query_endpoint = new NavigationEndpoint(data.originalQueryEndpoint); } } -export default ShowingResultsFor; + +export default ShowingResultsFor; \ No newline at end of file diff --git a/src/parser/classes/SimpleCardTeaser.js b/src/parser/classes/SimpleCardTeaser.js index b1e135c3..4d44ba1c 100644 --- a/src/parser/classes/SimpleCardTeaser.js +++ b/src/parser/classes/SimpleCardTeaser.js @@ -1,13 +1,14 @@ import Text from './misc/Text'; - import { YTNode } from '../helpers'; class SimpleCardTeaser extends YTNode { static type = 'SimpleCardTeaser'; + constructor(data) { super(); this.message = new Text(data.message); this.prominent = data.prominent; } } -export default SimpleCardTeaser; + +export default SimpleCardTeaser; \ No newline at end of file diff --git a/src/parser/classes/SingleActionEmergencySupport.js b/src/parser/classes/SingleActionEmergencySupport.js index 5ff5440f..06e8a758 100644 --- a/src/parser/classes/SingleActionEmergencySupport.js +++ b/src/parser/classes/SingleActionEmergencySupport.js @@ -1,10 +1,10 @@ import Text from './misc/Text'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class SingleActionEmergencySupport extends YTNode { static type = 'SingleActionEmergencySupport'; + constructor(data) { super(); this.action_text = new Text(data.actionText); @@ -14,4 +14,5 @@ class SingleActionEmergencySupport extends YTNode { this.endpoint = new NavigationEndpoint(data.navigationEndpoint); } } -export default SingleActionEmergencySupport; + +export default SingleActionEmergencySupport; \ No newline at end of file diff --git a/src/parser/classes/SingleColumnBrowseResults.js b/src/parser/classes/SingleColumnBrowseResults.js index f32c1ede..e7f08bfb 100644 --- a/src/parser/classes/SingleColumnBrowseResults.js +++ b/src/parser/classes/SingleColumnBrowseResults.js @@ -1,12 +1,13 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class SingleColumnBrowseResults extends YTNode { static type = 'SingleColumnBrowseResults'; + constructor(data) { super(); this.tabs = Parser.parse(data.tabs); } } -export default SingleColumnBrowseResults; + +export default SingleColumnBrowseResults; \ No newline at end of file diff --git a/src/parser/classes/SingleColumnMusicWatchNextResults.js b/src/parser/classes/SingleColumnMusicWatchNextResults.js index 9e93182e..63febc7e 100644 --- a/src/parser/classes/SingleColumnMusicWatchNextResults.js +++ b/src/parser/classes/SingleColumnMusicWatchNextResults.js @@ -1,12 +1,13 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class SingleColumnMusicWatchNextResults extends YTNode { static type = 'SingleColumnMusicWatchNextResults'; + constructor(data) { super(); return Parser.parse(data); } } -export default SingleColumnMusicWatchNextResults; + +export default SingleColumnMusicWatchNextResults; \ No newline at end of file diff --git a/src/parser/classes/SingleHeroImage.js b/src/parser/classes/SingleHeroImage.js index 162c2720..04c44714 100644 --- a/src/parser/classes/SingleHeroImage.js +++ b/src/parser/classes/SingleHeroImage.js @@ -1,13 +1,14 @@ import Thumbnail from './misc/Thumbnail'; - import { YTNode } from '../helpers'; class SingleHeroImage extends YTNode { static type = 'SingleHeroImage'; + constructor(data) { super(); this.thumbnails = new Thumbnail(data.thumbnail).thumbnails; this.style = data.style; } } -export default SingleHeroImage; + +export default SingleHeroImage; \ No newline at end of file diff --git a/src/parser/classes/SortFilterSubMenu.js b/src/parser/classes/SortFilterSubMenu.js index 91f4d946..6f7814fd 100644 --- a/src/parser/classes/SortFilterSubMenu.js +++ b/src/parser/classes/SortFilterSubMenu.js @@ -3,6 +3,7 @@ import { YTNode } from '../helpers'; class SortFilterSubMenu extends YTNode { static type = 'SortFilterSubMenu'; + constructor(data) { super(); this.sub_menu_items = observe(data.subMenuItems.map((item) => ({ @@ -11,7 +12,9 @@ class SortFilterSubMenu extends YTNode { continuation: item.continuation?.reloadContinuationData.continuation, subtitle: item.subtitle }))); + this.label = data.accessibility.accessibilityData.label; } } -export default SortFilterSubMenu; + +export default SortFilterSubMenu; \ No newline at end of file diff --git a/src/parser/classes/SubFeedOption.js b/src/parser/classes/SubFeedOption.js index 94f61562..68cbbd9f 100644 --- a/src/parser/classes/SubFeedOption.js +++ b/src/parser/classes/SubFeedOption.js @@ -1,10 +1,10 @@ import Text from './misc/Text'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class SubFeedOption extends YTNode { static type = 'SubFeedOption'; + constructor(data) { super(); this.name = new Text(data.name); @@ -12,4 +12,5 @@ class SubFeedOption extends YTNode { this.endpoint = new NavigationEndpoint(data.navigationEndpoint); } } -export default SubFeedOption; + +export default SubFeedOption; \ No newline at end of file diff --git a/src/parser/classes/SubFeedSelector.js b/src/parser/classes/SubFeedSelector.js index dbe4f0be..1782cd52 100644 --- a/src/parser/classes/SubFeedSelector.js +++ b/src/parser/classes/SubFeedSelector.js @@ -1,14 +1,15 @@ import Parser from '../index'; import Text from './misc/Text'; - import { YTNode } from '../helpers'; class SubFeedSelector extends YTNode { static type = 'SubFeedSelector'; + constructor(data) { super(); this.title = new Text(data.title); this.options = Parser.parse(data.options); } } -export default SubFeedSelector; + +export default SubFeedSelector; \ No newline at end of file diff --git a/src/parser/classes/SubscribeButton.js b/src/parser/classes/SubscribeButton.js index 39eb77f6..f43a323b 100644 --- a/src/parser/classes/SubscribeButton.js +++ b/src/parser/classes/SubscribeButton.js @@ -1,11 +1,11 @@ import Parser from '../index'; import Text from './misc/Text'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class SubscribeButton extends YTNode { static type = 'SubscribeButton'; + constructor(data) { super(); this.title = new Text(data.buttonText); @@ -20,4 +20,5 @@ class SubscribeButton extends YTNode { this.endpoint = new NavigationEndpoint(data.serviceEndpoints?.[0] || data.onSubscribeEndpoints?.[0]); } } -export default SubscribeButton; + +export default SubscribeButton; \ No newline at end of file diff --git a/src/parser/classes/SubscriptionNotificationToggleButton.js b/src/parser/classes/SubscriptionNotificationToggleButton.js index a796f3c7..f26d6c8b 100644 --- a/src/parser/classes/SubscriptionNotificationToggleButton.js +++ b/src/parser/classes/SubscriptionNotificationToggleButton.js @@ -1,9 +1,9 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class SubscriptionNotificationToggleButton extends YTNode { static type = 'SubscriptionNotificationToggleButton'; + constructor(data) { super(); this.states = data.states.map((state) => ({ @@ -11,8 +11,10 @@ class SubscriptionNotificationToggleButton extends YTNode { next_id: state.nextStateId, state: Parser.parse(state.state) })); + this.current_state_id = data.currentStateId; this.target_id = data.targetId; } } -export default SubscriptionNotificationToggleButton; + +export default SubscriptionNotificationToggleButton; \ No newline at end of file diff --git a/src/parser/classes/Tab.ts b/src/parser/classes/Tab.ts index ce06c9f7..e2b36438 100644 --- a/src/parser/classes/Tab.ts +++ b/src/parser/classes/Tab.ts @@ -1,14 +1,15 @@ import Parser from '../index'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class Tab extends YTNode { static type = 'Tab'; + title: string; selected: boolean; endpoint; content; + constructor(data: any) { super(); this.title = data.title || 'N/A'; @@ -17,4 +18,5 @@ class Tab extends YTNode { this.content = Parser.parse(data.content); } } -export default Tab; + +export default Tab; \ No newline at end of file diff --git a/src/parser/classes/Tabbed.js b/src/parser/classes/Tabbed.js index 2c726a80..6082de2e 100644 --- a/src/parser/classes/Tabbed.js +++ b/src/parser/classes/Tabbed.js @@ -1,12 +1,13 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class Tabbed extends YTNode { static type = 'Tabbed'; + constructor(data) { super(); return Parser.parse(data); } } -export default Tabbed; + +export default Tabbed; \ No newline at end of file diff --git a/src/parser/classes/TabbedSearchResults.js b/src/parser/classes/TabbedSearchResults.js index e20afa50..20ba4d2a 100644 --- a/src/parser/classes/TabbedSearchResults.js +++ b/src/parser/classes/TabbedSearchResults.js @@ -1,12 +1,13 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class TabbedSearchResults extends YTNode { static type = 'TabbedSearchResults'; + constructor(data) { super(); this.tabs = Parser.parse(data.tabs); } } -export default TabbedSearchResults; + +export default TabbedSearchResults; \ No newline at end of file diff --git a/src/parser/classes/TextHeader.js b/src/parser/classes/TextHeader.js index e671103b..ad200640 100644 --- a/src/parser/classes/TextHeader.js +++ b/src/parser/classes/TextHeader.js @@ -1,13 +1,14 @@ import Text from './misc/Text'; - import { YTNode } from '../helpers'; class TextHeader extends YTNode { static type = 'TextHeader'; + constructor(data) { super(); this.title = new Text(data.title); this.style = data.style; } } -export default TextHeader; + +export default TextHeader; \ No newline at end of file diff --git a/src/parser/classes/ThumbnailOverlayBottomPanel.js b/src/parser/classes/ThumbnailOverlayBottomPanel.js index 0ab64044..e010bf59 100644 --- a/src/parser/classes/ThumbnailOverlayBottomPanel.js +++ b/src/parser/classes/ThumbnailOverlayBottomPanel.js @@ -1,11 +1,12 @@ - import { YTNode } from '../helpers'; class ThumbnailOverlayBottomPanel extends YTNode { static type = 'ThumbnailOverlayBottomPanel'; + constructor(data) { super(); this.type = data.icon.iconType; } } -export default ThumbnailOverlayBottomPanel; + +export default ThumbnailOverlayBottomPanel; \ No newline at end of file diff --git a/src/parser/classes/ThumbnailOverlayEndorsement.js b/src/parser/classes/ThumbnailOverlayEndorsement.js index 1a7cf6e5..0effcea9 100644 --- a/src/parser/classes/ThumbnailOverlayEndorsement.js +++ b/src/parser/classes/ThumbnailOverlayEndorsement.js @@ -1,12 +1,13 @@ import Text from './misc/Text'; - import { YTNode } from '../helpers'; class ThumbnailOverlayEndorsement extends YTNode { static type = 'ThumbnailOverlayEndorsement'; + constructor(data) { super(); this.text = new Text(data.text).toString(); } } -export default ThumbnailOverlayEndorsement; + +export default ThumbnailOverlayEndorsement; \ No newline at end of file diff --git a/src/parser/classes/ThumbnailOverlayHoverText.js b/src/parser/classes/ThumbnailOverlayHoverText.js index cf59278f..4733f7c3 100644 --- a/src/parser/classes/ThumbnailOverlayHoverText.js +++ b/src/parser/classes/ThumbnailOverlayHoverText.js @@ -1,13 +1,14 @@ import Text from './misc/Text'; - import { YTNode } from '../helpers'; class ThumbnailOverlayHoverText extends YTNode { static type = 'ThumbnailOverlayHoverText'; + constructor(data) { super(); this.text = new Text(data.text); this.type = data.icon.iconType; } } -export default ThumbnailOverlayHoverText; + +export default ThumbnailOverlayHoverText; \ No newline at end of file diff --git a/src/parser/classes/ThumbnailOverlayInlineUnplayable.js b/src/parser/classes/ThumbnailOverlayInlineUnplayable.js index 304c3ba2..94d35ccf 100644 --- a/src/parser/classes/ThumbnailOverlayInlineUnplayable.js +++ b/src/parser/classes/ThumbnailOverlayInlineUnplayable.js @@ -1,13 +1,14 @@ import Text from './misc/Text'; - import { YTNode } from '../helpers'; class ThumbnailOverlayInlineUnplayable extends YTNode { static type = 'ThumbnailOverlayInlineUnplayable'; + constructor(data) { super(); this.text = new Text(data.text).toString(); this.icon_type = data.icon.iconType; } } -export default ThumbnailOverlayInlineUnplayable; + +export default ThumbnailOverlayInlineUnplayable; \ No newline at end of file diff --git a/src/parser/classes/ThumbnailOverlayLoadingPreview.js b/src/parser/classes/ThumbnailOverlayLoadingPreview.js index f0d28ea0..882fd7ca 100644 --- a/src/parser/classes/ThumbnailOverlayLoadingPreview.js +++ b/src/parser/classes/ThumbnailOverlayLoadingPreview.js @@ -1,12 +1,13 @@ import Text from './misc/Text'; - import { YTNode } from '../helpers'; class ThumbnailOverlayLoadingPreview extends YTNode { static type = 'ThumbnailOverlayLoadingPreview'; + constructor(data) { super(); this.text = new Text(data.text); } } -export default ThumbnailOverlayLoadingPreview; + +export default ThumbnailOverlayLoadingPreview; \ No newline at end of file diff --git a/src/parser/classes/ThumbnailOverlayNowPlaying.js b/src/parser/classes/ThumbnailOverlayNowPlaying.js index cf1b6c5b..a4c59802 100644 --- a/src/parser/classes/ThumbnailOverlayNowPlaying.js +++ b/src/parser/classes/ThumbnailOverlayNowPlaying.js @@ -1,12 +1,13 @@ import Text from './misc/Text'; - import { YTNode } from '../helpers'; class ThumbnailOverlayNowPlaying extends YTNode { static type = 'ThumbnailOverlayNowPlaying'; + constructor(data) { super(); this.text = new Text(data.text).text; } } -export default ThumbnailOverlayNowPlaying; + +export default ThumbnailOverlayNowPlaying; \ No newline at end of file diff --git a/src/parser/classes/ThumbnailOverlayPinking.js b/src/parser/classes/ThumbnailOverlayPinking.js index 8d166037..801b954d 100644 --- a/src/parser/classes/ThumbnailOverlayPinking.js +++ b/src/parser/classes/ThumbnailOverlayPinking.js @@ -1,11 +1,12 @@ - import { YTNode } from '../helpers'; class ThumbnailOverlayPinking extends YTNode { static type = 'ThumbnailOverlayPinking'; + constructor(data) { super(); this.hack = data.hack; } } -export default ThumbnailOverlayPinking; + +export default ThumbnailOverlayPinking; \ No newline at end of file diff --git a/src/parser/classes/ThumbnailOverlayPlaybackStatus.js b/src/parser/classes/ThumbnailOverlayPlaybackStatus.js index 62170935..4a82efa4 100644 --- a/src/parser/classes/ThumbnailOverlayPlaybackStatus.js +++ b/src/parser/classes/ThumbnailOverlayPlaybackStatus.js @@ -1,12 +1,13 @@ import Text from './misc/Text'; - import { YTNode } from '../helpers'; class ThumbnailOverlayPlaybackStatus extends YTNode { static type = 'ThumbnailOverlayPlaybackStatus'; + constructor(data) { super(); this.text = data.texts.map((text) => new Text(text))[0].toString(); } } -export default ThumbnailOverlayPlaybackStatus; + +export default ThumbnailOverlayPlaybackStatus; \ No newline at end of file diff --git a/src/parser/classes/ThumbnailOverlayResumePlayback.js b/src/parser/classes/ThumbnailOverlayResumePlayback.js index 234c15c2..b034ed7a 100644 --- a/src/parser/classes/ThumbnailOverlayResumePlayback.js +++ b/src/parser/classes/ThumbnailOverlayResumePlayback.js @@ -1,11 +1,12 @@ - import { YTNode } from '../helpers'; class ThumbnailOverlayResumePlayback extends YTNode { static type = 'ThumbnailOverlayResumePlayback'; + constructor(data) { super(); this.percent_duration_watched = data.percentDurationWatched; } } -export default ThumbnailOverlayResumePlayback; + +export default ThumbnailOverlayResumePlayback; \ No newline at end of file diff --git a/src/parser/classes/ThumbnailOverlaySidePanel.js b/src/parser/classes/ThumbnailOverlaySidePanel.js index 4b8fab8e..8679429c 100644 --- a/src/parser/classes/ThumbnailOverlaySidePanel.js +++ b/src/parser/classes/ThumbnailOverlaySidePanel.js @@ -1,13 +1,14 @@ import Text from './misc/Text'; - import { YTNode } from '../helpers'; class ThumbnailOverlaySidePanel extends YTNode { static type = 'ThumbnailOverlaySidePanel'; + constructor(data) { super(); this.text = new Text(data.text); this.type = data.icon.iconType; } } -export default ThumbnailOverlaySidePanel; + +export default ThumbnailOverlaySidePanel; \ No newline at end of file diff --git a/src/parser/classes/ThumbnailOverlayTimeStatus.js b/src/parser/classes/ThumbnailOverlayTimeStatus.js index 9d1578f6..3df4c92e 100644 --- a/src/parser/classes/ThumbnailOverlayTimeStatus.js +++ b/src/parser/classes/ThumbnailOverlayTimeStatus.js @@ -1,12 +1,13 @@ import Text from './misc/Text'; - import { YTNode } from '../helpers'; class ThumbnailOverlayTimeStatus extends YTNode { static type = 'ThumbnailOverlayTimeStatus'; + constructor(data) { super(); this.text = new Text(data.text).text; } } -export default ThumbnailOverlayTimeStatus; + +export default ThumbnailOverlayTimeStatus; \ No newline at end of file diff --git a/src/parser/classes/ThumbnailOverlayToggleButton.js b/src/parser/classes/ThumbnailOverlayToggleButton.js index 1b58defc..ac2a962a 100644 --- a/src/parser/classes/ThumbnailOverlayToggleButton.js +++ b/src/parser/classes/ThumbnailOverlayToggleButton.js @@ -1,22 +1,26 @@ import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class ThumbnailOverlayToggleButton extends YTNode { static type = 'ThumbnailOverlayToggleButton'; + constructor(data) { super(); 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); } } -export default ThumbnailOverlayToggleButton; + +export default ThumbnailOverlayToggleButton; \ No newline at end of file diff --git a/src/parser/classes/ToggleButton.js b/src/parser/classes/ToggleButton.js index 49504326..4dae2282 100644 --- a/src/parser/classes/ToggleButton.js +++ b/src/parser/classes/ToggleButton.js @@ -1,10 +1,10 @@ import Text from './misc/Text'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class ToggleButton extends YTNode { static type = 'ToggleButton'; + constructor(data) { super(); this.text = new Text(data.defaultText); @@ -14,17 +14,22 @@ class ToggleButton extends YTNode { this.is_toggled = data.isToggled; this.is_disabled = data.isDisabled; this.icon_type = data.defaultIcon.iconType; - const acc_label = data?.defaultText?.accessibility?.accessibilityData.label || - data?.accessibilityData?.accessibilityData.label || - data?.accessibility?.label; + + const acc_label = + data?.defaultText?.accessibility?.accessibilityData.label || + data?.accessibilityData?.accessibilityData.label || + data?.accessibility?.label; + if (this.icon_type == 'LIKE') { this.like_count = parseInt(acc_label.replace(/\D/g, '')); this.short_like_count = new Text(data.defaultText).toString(); } + this.endpoint = - data.defaultServiceEndpoint?.commandExecutorCommand?.commands ? - new NavigationEndpoint(data.defaultServiceEndpoint.commandExecutorCommand.commands.pop()) : - new NavigationEndpoint(data.defaultServiceEndpoint); + data.defaultServiceEndpoint?.commandExecutorCommand?.commands ? + new NavigationEndpoint(data.defaultServiceEndpoint.commandExecutorCommand.commands.pop()) : + new NavigationEndpoint(data.defaultServiceEndpoint); + this.toggled_endpoint = new NavigationEndpoint(data.toggledServiceEndpoint); this.button_id = data.toggleButtonSupportedData?.toggleButtonIdData?.id || null; this.target_id = data.targetId || null; diff --git a/src/parser/classes/ToggleMenuServiceItem.js b/src/parser/classes/ToggleMenuServiceItem.js index cc964f57..5b0d57c8 100644 --- a/src/parser/classes/ToggleMenuServiceItem.js +++ b/src/parser/classes/ToggleMenuServiceItem.js @@ -1,10 +1,10 @@ import Text from './misc/Text'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class ToggleMenuServiceItem extends YTNode { static type = 'ToggleMenuServiceItem'; + constructor(data) { super(); this.text = new Text(data.defaultText); @@ -14,4 +14,5 @@ class ToggleMenuServiceItem extends YTNode { this.endpoint = new NavigationEndpoint(data.toggledServiceEndpoint); } } -export default ToggleMenuServiceItem; + +export default ToggleMenuServiceItem; \ No newline at end of file diff --git a/src/parser/classes/Tooltip.js b/src/parser/classes/Tooltip.js index 15c6cd61..e5f0869b 100644 --- a/src/parser/classes/Tooltip.js +++ b/src/parser/classes/Tooltip.js @@ -1,12 +1,13 @@ import Text from './misc/Text'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class Tooltip extends YTNode { static type = 'Tooltip'; + constructor(data) { super(); + this.promo_config = { promo_id: data.promoConfig.promoId, impression_endpoints: data.promoConfig.impressionEndpoints @@ -14,6 +15,7 @@ class Tooltip extends YTNode { 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; @@ -21,4 +23,5 @@ class Tooltip extends YTNode { this.dwell_time_ms = parseInt(data.dwellTimeMs); } } -export default Tooltip; + +export default Tooltip; \ No newline at end of file diff --git a/src/parser/classes/TwoColumnBrowseResults.js b/src/parser/classes/TwoColumnBrowseResults.js index 4c4cd986..bd0f8057 100644 --- a/src/parser/classes/TwoColumnBrowseResults.js +++ b/src/parser/classes/TwoColumnBrowseResults.js @@ -1,13 +1,14 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class TwoColumnBrowseResults extends YTNode { static type = 'TwoColumnBrowseResults'; + constructor(data) { super(); this.tabs = Parser.parse(data.tabs); this.secondary_contents = Parser.parse(data.secondaryContents); } } -export default TwoColumnBrowseResults; + +export default TwoColumnBrowseResults; \ No newline at end of file diff --git a/src/parser/classes/TwoColumnSearchResults.js b/src/parser/classes/TwoColumnSearchResults.js index e1240953..8605aeb0 100644 --- a/src/parser/classes/TwoColumnSearchResults.js +++ b/src/parser/classes/TwoColumnSearchResults.js @@ -1,13 +1,14 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class TwoColumnSearchResults extends YTNode { static type = 'TwoColumnSearchResults'; + constructor(data) { super(); this.primary_contents = Parser.parse(data.primaryContents); this.secondary_contents = Parser.parse(data.secondaryContents); } } -export default TwoColumnSearchResults; + +export default TwoColumnSearchResults; \ No newline at end of file diff --git a/src/parser/classes/TwoColumnWatchNextResults.js b/src/parser/classes/TwoColumnWatchNextResults.js index b84e1437..cd0936c5 100644 --- a/src/parser/classes/TwoColumnWatchNextResults.js +++ b/src/parser/classes/TwoColumnWatchNextResults.js @@ -1,9 +1,9 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class TwoColumnWatchNextResults extends YTNode { static type = 'TwoColumnWatchNextResults'; + constructor(data) { super(); this.results = Parser.parse(data.results?.results.contents, true); @@ -11,4 +11,5 @@ class TwoColumnWatchNextResults extends YTNode { this.conversation_bar = Parser.parse(data?.conversationBar); } } -export default TwoColumnWatchNextResults; + +export default TwoColumnWatchNextResults; \ No newline at end of file diff --git a/src/parser/classes/UniversalWatchCard.js b/src/parser/classes/UniversalWatchCard.js index 81dee29b..74f5ef67 100644 --- a/src/parser/classes/UniversalWatchCard.js +++ b/src/parser/classes/UniversalWatchCard.js @@ -1,9 +1,9 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class UniversalWatchCard extends YTNode { static type = 'UniversalWatchCard'; + constructor(data) { super(); this.header = Parser.parse(data.header); @@ -11,4 +11,5 @@ class UniversalWatchCard extends YTNode { this.sections = Parser.parse(data.sections); } } -export default UniversalWatchCard; + +export default UniversalWatchCard; \ No newline at end of file diff --git a/src/parser/classes/VerticalList.js b/src/parser/classes/VerticalList.js index b3a15c02..40b0da4b 100644 --- a/src/parser/classes/VerticalList.js +++ b/src/parser/classes/VerticalList.js @@ -1,19 +1,21 @@ import Parser from '../index'; import Text from './misc/Text'; - import { YTNode } from '../helpers'; class VerticalList extends YTNode { static type = 'VerticalList'; + constructor(data) { super(); this.items = Parser.parse(data.items); this.collapsed_item_count = data.collapsedItemCount; this.collapsed_state_button_text = new Text(data.collapsedStateButtonText); } + // XXX: alias for consistency get contents() { return this.items; } } -export default VerticalList; + +export default VerticalList; \ No newline at end of file diff --git a/src/parser/classes/VerticalWatchCardList.js b/src/parser/classes/VerticalWatchCardList.js index 34f804bf..e6cfccf8 100644 --- a/src/parser/classes/VerticalWatchCardList.js +++ b/src/parser/classes/VerticalWatchCardList.js @@ -1,11 +1,11 @@ import Parser from '../index'; import Text from './misc/Text'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class VerticalWatchCardList extends YTNode { static type = 'VerticalWatchCardList'; + constructor(data) { super(); this.items = Parser.parse(data.items); @@ -14,4 +14,5 @@ class VerticalWatchCardList extends YTNode { this.view_all_endpoint = new NavigationEndpoint(data.viewAllEndpoint); } } -export default VerticalWatchCardList; + +export default VerticalWatchCardList; \ No newline at end of file diff --git a/src/parser/classes/Video.js b/src/parser/classes/Video.js index 15bbc201..2486cd9d 100644 --- a/src/parser/classes/Video.js +++ b/src/parser/classes/Video.js @@ -4,23 +4,27 @@ import Author from './misc/Author'; import Thumbnail from './misc/Thumbnail'; import NavigationEndpoint from './NavigationEndpoint'; import { timeToSeconds } from '../../utils/Utils'; - import { YTNode } from '../helpers'; class Video extends YTNode { static type = 'Video'; + constructor(data) { super(); + const overlay_time_status = data.thumbnailOverlays .find((overlay) => overlay.thumbnailOverlayTimeStatusRenderer) ?.thumbnailOverlayTimeStatusRenderer.text || 'N/A'; + this.id = data.videoId; this.title = new Text(data.title); this.description_snippet = data.descriptionSnippet ? new Text(data.descriptionSnippet, '') : null; + this.snippets = data.detailedMetadataSnippets?.map((snippet) => ({ text: new Text(snippet.snippetText), hover_text: new Text(snippet.snippetHoverText) })) || []; + this.thumbnails = Thumbnail.fromResponse(data.thumbnail); this.thumbnail_overlays = Parser.parse(data.thumbnailOverlays); this.rich_thumbnail = data.richThumbnail && Parser.parse(data.richThumbnail); @@ -29,17 +33,22 @@ class Video extends YTNode { this.published = new Text(data.publishedTimeText); this.view_count_text = new Text(data.viewCountText); this.short_view_count_text = new Text(data.shortViewCountText); + const upcoming = data.upcomingEventData && Number(`${data.upcomingEventData.startTime}000`); - if (upcoming) + if (upcoming) { this.upcoming = new Date(upcoming); + } + this.duration = { text: data.lengthText ? new Text(data.lengthText).text : new Text(overlay_time_status).text, seconds: 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 || false; this.menu = Parser.parse(data.menu); } + /** * @returns {string} */ @@ -49,24 +58,28 @@ class Video extends YTNode { } return this.description_snippet?.toString() || ''; } + /** * @type {boolean} */ get is_live() { return this.badges.some((badge) => badge.style === 'BADGE_STYLE_TYPE_LIVE_NOW'); } + /** * @type {boolean} */ get is_upcoming() { return this.upcoming && this.upcoming > new Date(); } + /** * @type {boolean} */ get has_captions() { return this.badges.some((badge) => badge.label === 'CC'); } + /** * @type {Thumbnail | undefined} */ @@ -74,4 +87,5 @@ class Video extends YTNode { return this.thumbnails[0]; } } -export default Video; + +export default Video; \ No newline at end of file diff --git a/src/parser/classes/VideoInfoCardContent.js b/src/parser/classes/VideoInfoCardContent.js index 5983e4e1..00d1ea83 100644 --- a/src/parser/classes/VideoInfoCardContent.js +++ b/src/parser/classes/VideoInfoCardContent.js @@ -1,11 +1,11 @@ import Text from './misc/Text'; import Thumbnail from './misc/Thumbnail'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class VideoInfoCardContent extends YTNode { static type = 'VideoInfoCardContent'; + constructor(data) { super(); this.title = new Text(data.videoTitle); @@ -16,4 +16,5 @@ class VideoInfoCardContent extends YTNode { this.endpoint = new NavigationEndpoint(data.action); } } -export default VideoInfoCardContent; + +export default VideoInfoCardContent; \ No newline at end of file diff --git a/src/parser/classes/VideoOwner.js b/src/parser/classes/VideoOwner.js index ddb07d07..0d99d1c5 100644 --- a/src/parser/classes/VideoOwner.js +++ b/src/parser/classes/VideoOwner.js @@ -1,18 +1,20 @@ import Text from './misc/Text'; import Author from './misc/Author'; - import { YTNode } from '../helpers'; class VideoOwner extends YTNode { static type = 'VideoOwner'; + constructor(data) { super(); this.subscription_button = data.subscriptionButton || null; this.subscriber_count = new Text(data.subscriberCountText); + this.author = new Author({ ...data.title, navigationEndpoint: data.navigationEndpoint }, data.badges, data.thumbnail); } } -export default VideoOwner; + +export default VideoOwner; \ No newline at end of file diff --git a/src/parser/classes/VideoPrimaryInfo.ts b/src/parser/classes/VideoPrimaryInfo.ts index cf605b77..83d50009 100644 --- a/src/parser/classes/VideoPrimaryInfo.ts +++ b/src/parser/classes/VideoPrimaryInfo.ts @@ -1,17 +1,18 @@ import Parser from '../index'; import Text from './misc/Text'; - import { YTNode } from '../helpers'; import Menu from './menus/Menu'; class VideoPrimaryInfo extends YTNode { static type = 'VideoPrimaryInfo'; + title; super_title_link; view_count; short_view_count; published; menu; + constructor(data: any) { super(); this.title = new Text(data.title); @@ -22,4 +23,5 @@ class VideoPrimaryInfo extends YTNode { this.menu = Parser.parseItem(data.videoActions, Menu); } } -export default VideoPrimaryInfo; + +export default VideoPrimaryInfo; \ No newline at end of file diff --git a/src/parser/classes/VideoSecondaryInfo.js b/src/parser/classes/VideoSecondaryInfo.js index fe6abc2b..0d472830 100644 --- a/src/parser/classes/VideoSecondaryInfo.js +++ b/src/parser/classes/VideoSecondaryInfo.js @@ -6,6 +6,7 @@ import MetadataRowContainer from './MetadataRowContainer'; class VideoSecondaryInfo extends YTNode { static type = 'VideoSecondaryInfo'; + constructor(data) { super(); this.owner = Parser.parse(data.owner); @@ -18,4 +19,5 @@ class VideoSecondaryInfo extends YTNode { this.description_collapsed_lines = data.descriptionCollapsedLines; } } -export default VideoSecondaryInfo; + +export default VideoSecondaryInfo; \ No newline at end of file diff --git a/src/parser/classes/WatchCardCompactVideo.js b/src/parser/classes/WatchCardCompactVideo.js index 07e355c4..21aa42c6 100644 --- a/src/parser/classes/WatchCardCompactVideo.js +++ b/src/parser/classes/WatchCardCompactVideo.js @@ -1,19 +1,22 @@ import Text from './misc/Text'; import { timeToSeconds } from '../../utils/Utils'; - import { YTNode } from '../helpers'; class WatchCardCompactVideo extends YTNode { static type = 'WatchCardCompactVideo'; + constructor(data) { super(); this.title = new Text(data.title); this.subtitle = new Text(data.subtitle); + this.duration = { text: new Text(data.lengthText).toString(), seconds: timeToSeconds(data.lengthText.simpleText) }; + this.style = data.style; } } -export default WatchCardCompactVideo; + +export default WatchCardCompactVideo; \ No newline at end of file diff --git a/src/parser/classes/WatchCardHeroVideo.js b/src/parser/classes/WatchCardHeroVideo.js index 09706c5e..c4d9c2f1 100644 --- a/src/parser/classes/WatchCardHeroVideo.js +++ b/src/parser/classes/WatchCardHeroVideo.js @@ -1,10 +1,10 @@ import Parser from '../index'; import NavigationEndpoint from './NavigationEndpoint'; - import { YTNode } from '../helpers'; class WatchCardHeroVideo extends YTNode { static type = 'WatchCardHeroVideo'; + constructor(data) { super(); this.endpoint = new NavigationEndpoint(data.navigationEndpoint); @@ -13,4 +13,5 @@ class WatchCardHeroVideo extends YTNode { this.label = data.accessibility.accessibilityData.label; } } -export default WatchCardHeroVideo; + +export default WatchCardHeroVideo; \ No newline at end of file diff --git a/src/parser/classes/WatchCardRichHeader.js b/src/parser/classes/WatchCardRichHeader.js index 415055e3..4d23a642 100644 --- a/src/parser/classes/WatchCardRichHeader.js +++ b/src/parser/classes/WatchCardRichHeader.js @@ -1,11 +1,11 @@ import Author from './misc/Author'; import NavigationEndpoint from './NavigationEndpoint'; import Text from './misc/Text'; - import { YTNode } from '../helpers'; class WatchCardRichHeader extends YTNode { static type = 'WatchCardRichHeader'; + constructor(data) { super(); this.title = new Text(data.title); @@ -16,4 +16,5 @@ class WatchCardRichHeader extends YTNode { this.style = data.style; } } -export default WatchCardRichHeader; + +export default WatchCardRichHeader; \ No newline at end of file diff --git a/src/parser/classes/WatchCardSectionSequence.js b/src/parser/classes/WatchCardSectionSequence.js index 5a467b6c..26d4de39 100644 --- a/src/parser/classes/WatchCardSectionSequence.js +++ b/src/parser/classes/WatchCardSectionSequence.js @@ -1,5 +1,4 @@ import Parser from '../index'; - import { YTNode } from '../helpers'; class WatchCardSectionSequence extends YTNode { @@ -9,4 +8,5 @@ class WatchCardSectionSequence extends YTNode { this.lists = Parser.parse(data.lists); } } -export default WatchCardSectionSequence; + +export default WatchCardSectionSequence; \ No newline at end of file diff --git a/src/parser/classes/WatchNextEndScreen.js b/src/parser/classes/WatchNextEndScreen.js index a302431a..17673683 100644 --- a/src/parser/classes/WatchNextEndScreen.js +++ b/src/parser/classes/WatchNextEndScreen.js @@ -1,14 +1,15 @@ import Parser from '../index'; import Text from './misc/Text'; - import { YTNode } from '../helpers'; class WatchNextEndScreen extends YTNode { static type = 'WatchNextEndScreen'; + constructor(data) { super(); this.results = Parser.parse(data.results); this.title = new Text(data.title).toString(); } } -export default WatchNextEndScreen; + +export default WatchNextEndScreen; \ No newline at end of file diff --git a/src/parser/classes/WatchNextTabbedResults.js b/src/parser/classes/WatchNextTabbedResults.js index 78a78f34..8b5f5be1 100644 --- a/src/parser/classes/WatchNextTabbedResults.js +++ b/src/parser/classes/WatchNextTabbedResults.js @@ -2,8 +2,10 @@ import TwoColumnBrowseResults from './TwoColumnBrowseResults'; class WatchNextTabbedResults extends TwoColumnBrowseResults { static type = 'WatchNextTabbedResults'; + constructor(data) { super(data); } } -export default WatchNextTabbedResults; + +export default WatchNextTabbedResults; \ No newline at end of file diff --git a/src/parser/classes/actions/AppendContinuationItemsAction.js b/src/parser/classes/actions/AppendContinuationItemsAction.js index 3762bc71..15947ea0 100644 --- a/src/parser/classes/actions/AppendContinuationItemsAction.js +++ b/src/parser/classes/actions/AppendContinuationItemsAction.js @@ -1,13 +1,14 @@ import Parser from '../../index'; - import { YTNode } from '../../helpers'; class AppendContinuationItemsAction extends YTNode { static type = 'AppendContinuationItemsAction'; + constructor(data) { super(); this.items = Parser.parse(data.continuationItems); this.target = data.target; } } -export default AppendContinuationItemsAction; + +export default AppendContinuationItemsAction; \ No newline at end of file diff --git a/src/parser/classes/actions/OpenPopupAction.js b/src/parser/classes/actions/OpenPopupAction.js index 38ed4fb8..9c66dfc4 100644 --- a/src/parser/classes/actions/OpenPopupAction.js +++ b/src/parser/classes/actions/OpenPopupAction.js @@ -1,13 +1,14 @@ import Parser from '../../index'; - import { YTNode } from '../../helpers'; class OpenPopupAction extends YTNode { static type = 'OpenPopupAction'; + constructor(data) { super(); this.popup = Parser.parse(data.popup); this.popup_type = data.popupType; } } -export default OpenPopupAction; + +export default OpenPopupAction; \ No newline at end of file diff --git a/src/parser/classes/comments/AuthorCommentBadge.js b/src/parser/classes/comments/AuthorCommentBadge.js index aa1bcc66..d1d615ca 100644 --- a/src/parser/classes/comments/AuthorCommentBadge.js +++ b/src/parser/classes/comments/AuthorCommentBadge.js @@ -1,21 +1,27 @@ - import { YTNode } from '../../helpers'; class AuthorCommentBadge extends YTNode { static type = 'AuthorCommentBadge'; + #data; + constructor(data) { super(); - this.icon_type = data.icon.iconType; + + this.icon_type = data.icon?.iconType || null; this.tooltip = data.iconTooltip; + // *** For consistency this.tooltip === 'Verified' && - (this.style = 'BADGE_STYLE_TYPE_VERIFIED') && - (data.style = 'BADGE_STYLE_TYPE_VERIFIED'); + (this.style = 'BADGE_STYLE_TYPE_VERIFIED') && + (data.style = 'BADGE_STYLE_TYPE_VERIFIED'); + this.#data = data; } + get orig_badge() { return this.#data; } } -export default AuthorCommentBadge; + +export default AuthorCommentBadge; \ No newline at end of file diff --git a/src/parser/classes/comments/Comment.js b/src/parser/classes/comments/Comment.js index 6434ed39..76bdb044 100644 --- a/src/parser/classes/comments/Comment.js +++ b/src/parser/classes/comments/Comment.js @@ -4,12 +4,13 @@ import Thumbnail from '../misc/Thumbnail'; import Author from '../misc/Author'; import Proto from '../../../proto/index'; import { InnertubeError } from '../../../utils/Utils'; - import { YTNode } from '../../helpers'; class Comment extends YTNode { static type = 'Comment'; + #actions; + constructor(data) { super(); this.content = new Text(data.contentText); @@ -17,38 +18,47 @@ class Comment extends YTNode { this.author_is_channel_owner = data.authorIsChannelOwner; this.current_user_reply_thumbnail = Thumbnail.fromResponse(data.currentUserReplyThumbnail); this.author_badge = Parser.parse(data.authorCommentBadge); + this.author = new Author({ ...data.authorText, navigationEndpoint: data.authorEndpoint }, this.author_badge ? [ { metadataBadgeRenderer: this.author_badge?.orig_badge } ] : null, data.authorThumbnail); + this.action_menu = Parser.parse(data.actionMenu); this.action_buttons = Parser.parse(data.actionButtons); this.comment_id = data.commentId; this.vote_status = data.voteStatus; + this.vote_count = { text: data.voteCount ? data.voteCount.accessibility.accessibilityData?.label.replace(/\D/g, '') : '0', short_text: data.voteCount ? new Text(data.voteCount).toString() : '0' }; + this.reply_count = data.replyCount || 0; - this.is_liked = this.action_buttons.item().like_button.is_toggled; - this.is_disliked = this.action_buttons.item().dislike_button.is_toggled; + this.is_liked = this.action_buttons.item().like_button.item().is_toggled; + this.is_disliked = this.action_buttons.item().dislike_button.item().is_toggled; this.is_pinned = !!data.pinnedCommentBadge; } + /** * API response. * @typedef {{ success: boolean, status_code: number, data: object }} Response */ + /** * Likes the comment. * @returns {Promise.} */ async like() { - const button = this.action_buttons.like_button; + const button = this.action_buttons.item().like_button.item(); + if (button.is_toggled) throw new InnertubeError('This comment is already liked', { comment_id: this.comment_id }); + const response = await button.endpoint.callTest(this.#actions, { parse: false }); + return response; } /** @@ -56,30 +66,39 @@ class Comment extends YTNode { * @returns {Promise.} */ async dislike() { - const button = this.action_buttons.dislike_button; + const button = this.action_buttons.item().dislike_button.item(); + if (button.is_toggled) throw new InnertubeError('This comment is already disliked', { comment_id: this.comment_id }); + const response = await button.endpoint.callTest(this.#actions, { parse: false }); + return response; } + /** * Creates a reply to the comment. * @param {string} text * @returns {Promise.} */ async reply(text) { - if (!this.action_buttons.reply_button) + if (!this.action_buttons.item().reply_button) throw new InnertubeError('Cannot reply to another reply. Try mentioning the user instead.', { comment_id: this.comment_id }); - const button = this.action_buttons.reply_button; - const dialog_button = button.endpoint.dialog.reply_button; + + const button = this.action_buttons.item().reply_button.item(); + const dialog_button = button.endpoint.dialog.item().reply_button.item(); + const payload = { params: { commentText: text } }; + const response = await dialog_button.endpoint.callTest(this.#actions, payload); + return response; } + /** * Translates the comment to the given language. * @param {string} target_language @@ -87,18 +106,23 @@ class Comment extends YTNode { async translate(target_language) { // Emojis must be removed otherwise InnerTube throws a 400 status code at us. const text = this.content.toString().replace(/[^\p{L}\p{N}\p{P}\p{Z}]/gu, ''); + const payload = { text, target_language, comment_id: this.comment_id }; + const action = Proto.encodeCommentActionParams(22, payload); const response = await this.#actions.execute('comment/perform_comment_action', { action, client: 'ANDROID' }); + // TODO: maybe add these to Parser#parseResponse? const mutations = response.data.frameworkUpdates.entityBatchUpdate.mutations; const content = mutations[0].payload.commentEntityPayload.translatedContent.content; + return { ...response, content }; } + /** * @param {import('../../../../core/Actions').default} actions */ @@ -106,4 +130,5 @@ class Comment extends YTNode { this.#actions = actions; } } -export default Comment; + +export default Comment; \ No newline at end of file diff --git a/src/parser/classes/comments/CommentActionButtons.js b/src/parser/classes/comments/CommentActionButtons.js index 08220750..5752b8fa 100644 --- a/src/parser/classes/comments/CommentActionButtons.js +++ b/src/parser/classes/comments/CommentActionButtons.js @@ -1,9 +1,9 @@ import Parser from '../../index'; - import { YTNode } from '../../helpers'; class CommentActionButtons extends YTNode { static type = 'CommentActionButtons'; + constructor(data) { super(); this.like_button = Parser.parse(data.likeButton); @@ -11,4 +11,5 @@ class CommentActionButtons extends YTNode { this.reply_button = Parser.parse(data.replyButton); } } -export default CommentActionButtons; + +export default CommentActionButtons; \ No newline at end of file diff --git a/src/parser/classes/comments/CommentReplies.js b/src/parser/classes/comments/CommentReplies.js index 827bd9c5..06a5db97 100644 --- a/src/parser/classes/comments/CommentReplies.js +++ b/src/parser/classes/comments/CommentReplies.js @@ -1,9 +1,9 @@ import Parser from '../../index'; - import { YTNode } from '../../helpers'; class CommentReplies extends YTNode { static type = 'CommentReplies'; + constructor(data) { super(); this.contents = Parser.parse(data.contents); @@ -11,4 +11,5 @@ class CommentReplies extends YTNode { this.hide_replies = Parser.parse(data.hideReplies); } } -export default CommentReplies; + +export default CommentReplies; \ No newline at end of file diff --git a/src/parser/classes/comments/CommentReplyDialog.js b/src/parser/classes/comments/CommentReplyDialog.js index e84a2f08..d1a86587 100644 --- a/src/parser/classes/comments/CommentReplyDialog.js +++ b/src/parser/classes/comments/CommentReplyDialog.js @@ -1,11 +1,11 @@ import Parser from '../../index'; import Thumbnail from '../misc/Thumbnail'; import Text from '../misc/Text'; - import { YTNode } from '../../helpers'; class CommentReplyDialog extends YTNode { static type = 'CommentReplyDialog'; + constructor(data) { super(); this.reply_button = Parser.parse(data.replyButton); @@ -15,4 +15,5 @@ class CommentReplyDialog extends YTNode { this.error_message = new Text(data.errorMessage); } } -export default CommentReplyDialog; + +export default CommentReplyDialog; \ No newline at end of file diff --git a/src/parser/classes/comments/CommentSimplebox.js b/src/parser/classes/comments/CommentSimplebox.js index 0c5a4c34..2e0e7d9a 100644 --- a/src/parser/classes/comments/CommentSimplebox.js +++ b/src/parser/classes/comments/CommentSimplebox.js @@ -1,11 +1,11 @@ import Parser from '../../index'; import Thumbnail from '../misc/Thumbnail'; import Text from '../misc/Text'; - import { YTNode } from '../../helpers'; class CommentSimplebox extends YTNode { static type = 'CommentSimplebox'; + constructor(data) { super(); this.submit_button = Parser.parse(data.submitButton); @@ -15,4 +15,5 @@ class CommentSimplebox extends YTNode { this.avatar_size = data.avatarSize; } } -export default CommentSimplebox; + +export default CommentSimplebox; \ No newline at end of file diff --git a/src/parser/classes/comments/CommentThread.ts b/src/parser/classes/comments/CommentThread.ts index 6dae42d5..1bb9345d 100644 --- a/src/parser/classes/comments/CommentThread.ts +++ b/src/parser/classes/comments/CommentThread.ts @@ -1,63 +1,80 @@ import Parser from '../../index'; -import { InnertubeError } from '../../../utils/Utils'; - -import { YTNode } from '../../helpers'; import Comment from './Comment'; import ContinuationItem from '../ContinuationItem'; import Actions from '../../../core/Actions'; import NavigationEndpoint from '../NavigationEndpoint'; +import { InnertubeError } from '../../../utils/Utils'; +import { YTNode } from '../../helpers'; + class CommentThread extends YTNode { static type = 'CommentThread'; + #replies; #actions?: Actions; #continuation?: ContinuationItem; is_moderated_elq_comment: boolean; comment; replies: Comment[] | undefined; + constructor(data: any) { super(); this.comment = Parser.parseItem(data.comment, Comment); this.#replies = Parser.parseItem(data.replies); this.is_moderated_elq_comment = data.isModeratedElqComment; } + /** * Retrieves replies to this comment thread. */ async getReplies() { if (!this.#actions) throw new InnertubeError('Actions not set for this CommentThread.'); + if (!this.#replies) throw new InnertubeError('This comment has no replies.', { comment_id: this.comment?.comment_id }); + const continuation = this.#replies.key('contents').parsed().array().get({ type: 'ContinuationItem' })?.as(ContinuationItem); const response = await continuation?.endpoint.callTest(this.#actions); + this.replies = response?.on_response_received_endpoints_memo?.getType(Comment).map((comment) => { comment.setActions(this.#actions); return comment; }); + this.#continuation = response?.on_response_received_endpoints_memo.getType(ContinuationItem)?.[0]; + return this; } + /** * Retrieves next batch of replies. */ async getContinuation() { if (!this.replies) throw new InnertubeError('Continuation not available.'); + if (!this.#continuation) throw new InnertubeError('Continuation not found.'); + if (!this.#actions) throw new InnertubeError('Actions not set for this CommentThread.'); + const response = await this.#continuation.button?.item().key('endpoint').nodeOfType(NavigationEndpoint).callTest(this.#actions); + this.replies = response?.on_response_received_endpoints_memo.getType(Comment).map((comment) => { comment.setActions(this.#actions); return comment; }); + this.#continuation = response?.on_response_received_endpoints_memo.getType(ContinuationItem)?.[0]; + return this; } + setActions(actions: Actions) { this.#actions = actions; } } -export default CommentThread; + +export default CommentThread; \ No newline at end of file diff --git a/src/parser/classes/comments/CommentsEntryPointHeader.js b/src/parser/classes/comments/CommentsEntryPointHeader.js index 526f04eb..d0eb7804 100644 --- a/src/parser/classes/comments/CommentsEntryPointHeader.js +++ b/src/parser/classes/comments/CommentsEntryPointHeader.js @@ -1,10 +1,10 @@ import Text from '../misc/Text'; import Thumbnail from '../misc/Thumbnail'; - import { YTNode } from '../../helpers'; class CommentsEntryPointHeader extends YTNode { static type = 'CommentsEntryPointHeader'; + constructor(data) { super(); this.header = new Text(data.headerText); @@ -14,4 +14,5 @@ class CommentsEntryPointHeader extends YTNode { this.simplebox_placeholder = new Text(data.simpleboxPlaceholder); } } -export default CommentsEntryPointHeader; + +export default CommentsEntryPointHeader; \ No newline at end of file diff --git a/src/parser/classes/comments/CommentsHeader.js b/src/parser/classes/comments/CommentsHeader.js index eb4be347..3c557b85 100644 --- a/src/parser/classes/comments/CommentsHeader.js +++ b/src/parser/classes/comments/CommentsHeader.js @@ -1,11 +1,11 @@ import Parser from '../../index'; import Text from '../misc/Text'; import Thumbnail from '../misc/Thumbnail'; - import { YTNode } from '../../helpers'; class CommentsHeader extends YTNode { static type = 'CommentsHeader'; + constructor(data) { super(); this.title = new Text(data.titleText); @@ -13,6 +13,7 @@ class CommentsHeader extends YTNode { this.comments_count = new Text(data.commentsCount); this.create_renderer = Parser.parseItem(data.createRenderer); this.sort_menu = Parser.parse(data.sortMenu); + this.custom_emojis = data.customEmojis?.map((emoji) => ({ emoji_id: emoji.emojiId, shortcuts: emoji.shortcuts, @@ -22,4 +23,5 @@ class CommentsHeader extends YTNode { })) || null; } } -export default CommentsHeader; + +export default CommentsHeader; \ No newline at end of file diff --git a/src/parser/classes/livechat/AddBannerToLiveChatCommand.js b/src/parser/classes/livechat/AddBannerToLiveChatCommand.js index 7987b486..d2455945 100644 --- a/src/parser/classes/livechat/AddBannerToLiveChatCommand.js +++ b/src/parser/classes/livechat/AddBannerToLiveChatCommand.js @@ -1,12 +1,13 @@ import Parser from '../../index'; - import { YTNode } from '../../helpers'; class AddBannerToLiveChatCommand extends YTNode { static type = 'AddBannerToLiveChatCommand'; + constructor(data) { super(); return Parser.parse(data.bannerRenderer); } } -export default AddBannerToLiveChatCommand; + +export default AddBannerToLiveChatCommand; \ No newline at end of file diff --git a/src/parser/classes/livechat/AddChatItemAction.js b/src/parser/classes/livechat/AddChatItemAction.js index f6b59a2a..b1e09d75 100644 --- a/src/parser/classes/livechat/AddChatItemAction.js +++ b/src/parser/classes/livechat/AddChatItemAction.js @@ -1,13 +1,14 @@ import Parser from '../../index'; - import { YTNode } from '../../helpers'; class AddChatItemAction extends YTNode { static type = 'AddChatItemAction'; + constructor(data) { super(); this.item = Parser.parseItem(data.item); this.client_id = data.clientId || null; } } -export default AddChatItemAction; + +export default AddChatItemAction; \ No newline at end of file diff --git a/src/parser/classes/livechat/AddLiveChatTickerItemAction.js b/src/parser/classes/livechat/AddLiveChatTickerItemAction.js index 138f66de..70264d76 100644 --- a/src/parser/classes/livechat/AddLiveChatTickerItemAction.js +++ b/src/parser/classes/livechat/AddLiveChatTickerItemAction.js @@ -1,13 +1,14 @@ import Parser from '../../index'; - import { YTNode } from '../../helpers'; class AddLiveChatTickerItemAction extends YTNode { static type = 'AddLiveChatTickerItemAction'; + constructor(data) { super(); this.item = Parser.parseItem(data.item); this.duration_sec = data.durationSec; } } -export default AddLiveChatTickerItemAction; + +export default AddLiveChatTickerItemAction; \ No newline at end of file diff --git a/src/parser/classes/livechat/LiveChatActionPanel.js b/src/parser/classes/livechat/LiveChatActionPanel.js index ad6a14c3..686fbea3 100644 --- a/src/parser/classes/livechat/LiveChatActionPanel.js +++ b/src/parser/classes/livechat/LiveChatActionPanel.js @@ -1,9 +1,9 @@ import Parser from '../../index'; - import { YTNode } from '../../helpers'; class LiveChatActionPanel extends YTNode { static type = 'LiveChatActionPanel'; + constructor(data) { super(); this.id = data.id; @@ -11,4 +11,5 @@ class LiveChatActionPanel extends YTNode { this.target_id = data.targetId; } } -export default LiveChatActionPanel; + +export default LiveChatActionPanel; \ No newline at end of file diff --git a/src/parser/classes/livechat/MarkChatItemAsDeletedAction.js b/src/parser/classes/livechat/MarkChatItemAsDeletedAction.js index fe081ef5..8ff0a59d 100644 --- a/src/parser/classes/livechat/MarkChatItemAsDeletedAction.js +++ b/src/parser/classes/livechat/MarkChatItemAsDeletedAction.js @@ -1,13 +1,14 @@ import Text from '../misc/Text'; - import { YTNode } from '../../helpers'; class MarkChatItemAsDeletedAction extends YTNode { static type = 'MarkChatItemAsDeletedAction'; + constructor(data) { super(); this.deleted_state_message = new Text(data.deletedStateMessage); this.target_item_id = data.targetItemId; } } -export default MarkChatItemAsDeletedAction; + +export default MarkChatItemAsDeletedAction; \ No newline at end of file diff --git a/src/parser/classes/livechat/MarkChatItemsByAuthorAsDeletedAction.js b/src/parser/classes/livechat/MarkChatItemsByAuthorAsDeletedAction.js index ee39311d..197ed5db 100644 --- a/src/parser/classes/livechat/MarkChatItemsByAuthorAsDeletedAction.js +++ b/src/parser/classes/livechat/MarkChatItemsByAuthorAsDeletedAction.js @@ -1,13 +1,14 @@ import Text from '../misc/Text'; - import { YTNode } from '../../helpers'; class MarkChatItemsByAuthorAsDeletedAction extends YTNode { static type = 'MarkChatItemsByAuthorAsDeletedAction'; + constructor(data) { super(); this.deleted_state_message = new Text(data.deletedStateMessage); this.channel_id = data.externalChannelId; } } -export default MarkChatItemsByAuthorAsDeletedAction; + +export default MarkChatItemsByAuthorAsDeletedAction; \ No newline at end of file diff --git a/src/parser/classes/livechat/RemoveBannerForLiveChatCommand.js b/src/parser/classes/livechat/RemoveBannerForLiveChatCommand.js index ae15ab77..13529cd7 100644 --- a/src/parser/classes/livechat/RemoveBannerForLiveChatCommand.js +++ b/src/parser/classes/livechat/RemoveBannerForLiveChatCommand.js @@ -1,11 +1,12 @@ - import { YTNode } from '../../helpers'; class RemoveBannerForLiveChatCommand extends YTNode { static type = 'RemoveBannerForLiveChatCommand'; + constructor(data) { super(); this.target_action_id = data.targetActionId; } } -export default RemoveBannerForLiveChatCommand; + +export default RemoveBannerForLiveChatCommand; \ No newline at end of file diff --git a/src/parser/classes/livechat/ReplaceChatItemAction.js b/src/parser/classes/livechat/ReplaceChatItemAction.js index 0143eee0..6400e7fd 100644 --- a/src/parser/classes/livechat/ReplaceChatItemAction.js +++ b/src/parser/classes/livechat/ReplaceChatItemAction.js @@ -1,13 +1,14 @@ import Parser from '../../index'; - import { YTNode } from '../../helpers'; class ReplaceChatItemAction extends YTNode { static type = 'ReplaceChatItemAction'; + constructor(data) { super(); this.target_item_id = data.targetItemId; this.replacement_item = Parser.parse(data.replacementItem); } } -export default ReplaceChatItemAction; + +export default ReplaceChatItemAction; \ No newline at end of file diff --git a/src/parser/classes/livechat/ReplayChatItemAction.js b/src/parser/classes/livechat/ReplayChatItemAction.js index 1e820103..a167dcfa 100644 --- a/src/parser/classes/livechat/ReplayChatItemAction.js +++ b/src/parser/classes/livechat/ReplayChatItemAction.js @@ -1,9 +1,9 @@ import Parser from '../../index'; - import { YTNode } from '../../helpers'; class ReplayChatItemAction extends YTNode { static type = 'ReplayChatItemAction'; + constructor(data) { super(); this.actions = Parser.parse(data.actions?.map((action) => { @@ -13,4 +13,5 @@ class ReplayChatItemAction extends YTNode { this.video_offset_time_msec = data.videoOffsetTimeMsec; } } -export default ReplayChatItemAction; + +export default ReplayChatItemAction; \ No newline at end of file diff --git a/src/parser/classes/livechat/ShowLiveChatActionPanelAction.js b/src/parser/classes/livechat/ShowLiveChatActionPanelAction.js index 71b5b7ca..4c0a0f3c 100644 --- a/src/parser/classes/livechat/ShowLiveChatActionPanelAction.js +++ b/src/parser/classes/livechat/ShowLiveChatActionPanelAction.js @@ -1,12 +1,13 @@ import Parser from '../../index'; - import { YTNode } from '../../helpers'; class ShowLiveChatActionPanelAction extends YTNode { static type = 'ShowLiveChatActionPanelAction'; + constructor(data) { super(); this.panel_to_show = Parser.parse(data.panelToShow); } } -export default ShowLiveChatActionPanelAction; + +export default ShowLiveChatActionPanelAction; \ No newline at end of file diff --git a/src/parser/classes/livechat/ShowLiveChatTooltipCommand.js b/src/parser/classes/livechat/ShowLiveChatTooltipCommand.js index b16d7034..e350e813 100644 --- a/src/parser/classes/livechat/ShowLiveChatTooltipCommand.js +++ b/src/parser/classes/livechat/ShowLiveChatTooltipCommand.js @@ -1,12 +1,13 @@ import Parser from '../../index'; - import { YTNode } from '../../helpers'; class ShowLiveChatTooltipCommand extends YTNode { static type = 'ShowLiveChatTooltipCommand'; + constructor(data) { super(); this.tooltip = Parser.parse(data.tooltip); } } -export default ShowLiveChatTooltipCommand; + +export default ShowLiveChatTooltipCommand; \ No newline at end of file diff --git a/src/parser/classes/livechat/UpdateDateTextAction.js b/src/parser/classes/livechat/UpdateDateTextAction.js index f9cb64c4..be4b0965 100644 --- a/src/parser/classes/livechat/UpdateDateTextAction.js +++ b/src/parser/classes/livechat/UpdateDateTextAction.js @@ -1,12 +1,13 @@ import Text from '../misc/Text'; - import { YTNode } from '../../helpers'; class UpdateDateTextAction extends YTNode { static type = 'UpdateDateTextAction'; + constructor(data) { super(); this.date_text = new Text(data.dateText).toString(); } } -export default UpdateDateTextAction; + +export default UpdateDateTextAction; \ No newline at end of file diff --git a/src/parser/classes/livechat/UpdateDescriptionAction.js b/src/parser/classes/livechat/UpdateDescriptionAction.js index 622f5e74..85fd6255 100644 --- a/src/parser/classes/livechat/UpdateDescriptionAction.js +++ b/src/parser/classes/livechat/UpdateDescriptionAction.js @@ -1,12 +1,13 @@ import Text from '../misc/Text'; - import { YTNode } from '../../helpers'; class UpdateDescriptionAction extends YTNode { static type = 'UpdateDescriptionAction'; + constructor(data) { super(); this.description = new Text(data.description); } } -export default UpdateDescriptionAction; + +export default UpdateDescriptionAction; \ No newline at end of file diff --git a/src/parser/classes/livechat/UpdateLiveChatPollAction.js b/src/parser/classes/livechat/UpdateLiveChatPollAction.js index b0b9ca58..b2b9bbb0 100644 --- a/src/parser/classes/livechat/UpdateLiveChatPollAction.js +++ b/src/parser/classes/livechat/UpdateLiveChatPollAction.js @@ -1,12 +1,13 @@ import Parser from '../../index'; - import { YTNode } from '../../helpers'; class UpdateLiveChatPollAction extends YTNode { static type = 'UpdateLiveChatPollAction'; + constructor(data) { super(); this.poll_to_update = Parser.parse(data.pollToUpdate); } } -export default UpdateLiveChatPollAction; + +export default UpdateLiveChatPollAction; \ No newline at end of file diff --git a/src/parser/classes/livechat/UpdateTitleAction.js b/src/parser/classes/livechat/UpdateTitleAction.js index 3f76bdd3..5d6c30b3 100644 --- a/src/parser/classes/livechat/UpdateTitleAction.js +++ b/src/parser/classes/livechat/UpdateTitleAction.js @@ -1,12 +1,13 @@ import Text from '../misc/Text'; - import { YTNode } from '../../helpers'; class UpdateTitleAction extends YTNode { static type = 'UpdateTitleAction'; + constructor(data) { super(); this.title = new Text(data.title); } } -export default UpdateTitleAction; + +export default UpdateTitleAction; \ No newline at end of file diff --git a/src/parser/classes/livechat/UpdateToggleButtonTextAction.js b/src/parser/classes/livechat/UpdateToggleButtonTextAction.js index b6bcd900..e7f1f06c 100644 --- a/src/parser/classes/livechat/UpdateToggleButtonTextAction.js +++ b/src/parser/classes/livechat/UpdateToggleButtonTextAction.js @@ -1,9 +1,9 @@ import Text from '../misc/Text'; - import { YTNode } from '../../helpers'; class UpdateToggleButtonTextAction extends YTNode { static type = 'UpdateToggleButtonTextAction'; + constructor(data) { super(); this.default_text = new Text(data.defaultText).toString(); @@ -11,4 +11,5 @@ class UpdateToggleButtonTextAction extends YTNode { this.button_id = data.buttonId; } } -export default UpdateToggleButtonTextAction; + +export default UpdateToggleButtonTextAction; \ No newline at end of file diff --git a/src/parser/classes/livechat/UpdateViewershipAction.js b/src/parser/classes/livechat/UpdateViewershipAction.js index 429f5136..858bcdc6 100644 --- a/src/parser/classes/livechat/UpdateViewershipAction.js +++ b/src/parser/classes/livechat/UpdateViewershipAction.js @@ -1,9 +1,9 @@ import Text from '../misc/Text'; - import { YTNode } from '../../helpers'; class UpdateViewershipAction extends YTNode { static type = 'UpdateViewershipAction'; + constructor(data) { super(); const view_count_renderer = data.viewCount.videoViewCountRenderer; @@ -12,4 +12,5 @@ class UpdateViewershipAction extends YTNode { this.is_live = view_count_renderer.isLive; } } -export default UpdateViewershipAction; + +export default UpdateViewershipAction; \ No newline at end of file diff --git a/src/parser/classes/livechat/items/LiveChatBanner.js b/src/parser/classes/livechat/items/LiveChatBanner.js index 1163ffdc..0b9643cf 100644 --- a/src/parser/classes/livechat/items/LiveChatBanner.js +++ b/src/parser/classes/livechat/items/LiveChatBanner.js @@ -1,8 +1,9 @@ import Parser from '../../../index'; - import { YTNode } from '../../../helpers'; + class LiveChatBanner extends YTNode { static type = 'LiveChatBanner'; + constructor(data) { super(); this.header = Parser.parse(data.header, 'livechat/items'); @@ -14,4 +15,5 @@ class LiveChatBanner extends YTNode { this.background_type = data.backgroundType; } } -export default LiveChatBanner; + +export default LiveChatBanner; \ No newline at end of file diff --git a/src/parser/classes/livechat/items/LiveChatBannerHeader.js b/src/parser/classes/livechat/items/LiveChatBannerHeader.js index 0308b0f7..59eb76e5 100644 --- a/src/parser/classes/livechat/items/LiveChatBannerHeader.js +++ b/src/parser/classes/livechat/items/LiveChatBannerHeader.js @@ -1,9 +1,10 @@ import Parser from '../../../index'; import Text from '../../misc/Text'; - import { YTNode } from '../../../helpers'; + class LiveChatBannerHeader extends YTNode { static type = 'LiveChatBannerHeader'; + constructor(data) { super(); this.text = new Text(data.text).toString(); @@ -11,4 +12,5 @@ class LiveChatBannerHeader extends YTNode { this.context_menu_button = Parser.parse(data.contextMenuButton); } } -export default LiveChatBannerHeader; + +export default LiveChatBannerHeader; \ No newline at end of file diff --git a/src/parser/classes/livechat/items/LiveChatBannerPoll.js b/src/parser/classes/livechat/items/LiveChatBannerPoll.js index 9e3a8711..bf9f59d8 100644 --- a/src/parser/classes/livechat/items/LiveChatBannerPoll.js +++ b/src/parser/classes/livechat/items/LiveChatBannerPoll.js @@ -1,21 +1,25 @@ import Parser from '../../../index'; import Text from '../../misc/Text'; import Thumbnail from '../../misc/Thumbnail'; - import { YTNode } from '../../../helpers'; + class LiveChatBannerPoll extends YTNode { static type = 'LiveChatBannerPoll'; + constructor(data) { super(); this.poll_question = new Text(data.pollQuestion); this.author_photo = Thumbnail.fromResponse(data.authorPhoto); + this.choices = data.pollChoices.map((choice) => ({ option_id: choice.pollOptionId, text: new Text(choice.text).toString() })); + this.collapsed_state_entity_key = data.collapsedStateEntityKey; this.live_chat_poll_state_entity_key = data.liveChatPollStateEntityKey; this.context_menu_button = Parser.parse(data.contextMenuButton); } } -export default LiveChatBannerPoll; + +export default LiveChatBannerPoll; \ No newline at end of file diff --git a/src/parser/classes/livechat/items/LiveChatMembershipItem.js b/src/parser/classes/livechat/items/LiveChatMembershipItem.js index 0617157f..2db92227 100644 --- a/src/parser/classes/livechat/items/LiveChatMembershipItem.js +++ b/src/parser/classes/livechat/items/LiveChatMembershipItem.js @@ -2,22 +2,26 @@ import Parser from '../../../index'; import Text from '../../misc/Text'; import Thumbnail from '../../misc/Thumbnail'; import NavigationEndpoint from '../../NavigationEndpoint'; - import { YTNode } from '../../../helpers'; + class LiveChatMembershipItem extends YTNode { static type = 'LiveChatMembershipItem'; + constructor(data) { super(); this.id = data.id; this.timestamp = Math.floor(parseInt(data.timestampUsec) / 1000); this.header_subtext = new Text(data.headerSubtext); + this.author = { id: data.authorExternalChannelId, name: new Text(data?.authorName), thumbnails: Thumbnail.fromResponse(data.authorPhoto), badges: Parser.parse(data.authorBadges) }; + this.menu_endpoint = new NavigationEndpoint(data.contextMenuEndpoint); } } -export default LiveChatMembershipItem; + +export default LiveChatMembershipItem; \ No newline at end of file diff --git a/src/parser/classes/livechat/items/LiveChatPaidMessage.js b/src/parser/classes/livechat/items/LiveChatPaidMessage.js index c065e772..8c77c51e 100644 --- a/src/parser/classes/livechat/items/LiveChatPaidMessage.js +++ b/src/parser/classes/livechat/items/LiveChatPaidMessage.js @@ -2,20 +2,24 @@ import Text from '../../misc/Text'; import Thumbnail from '../../misc/Thumbnail'; import NavigationEndpoint from '../../NavigationEndpoint'; import Parser from '../../../index'; - import { YTNode } from '../../../helpers'; + class LiveChatPaidMessage extends YTNode { static type = 'LiveChatPaidMessage'; + constructor(data) { super(); this.message = new Text(data.message); + this.author = { id: data.authorExternalChannelId, name: new Text(data.authorName), thumbnails: Thumbnail.fromResponse(data.authorPhoto), badges: Parser.parse(data.authorBadges) }; + const badges = Parser.parse(data.authorBadges); + this.author.badges = badges; this.author.is_moderator = badges?.some((badge) => badge.icon_type == 'MODERATOR') || null; this.author.is_verified = badges?.some((badge) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED') || null; @@ -27,4 +31,5 @@ class LiveChatPaidMessage extends YTNode { this.id = data.id; } } -export default LiveChatPaidMessage; + +export default LiveChatPaidMessage; \ No newline at end of file diff --git a/src/parser/classes/livechat/items/LiveChatPaidSticker.js b/src/parser/classes/livechat/items/LiveChatPaidSticker.js index 7d7babba..ed99da9e 100644 --- a/src/parser/classes/livechat/items/LiveChatPaidSticker.js +++ b/src/parser/classes/livechat/items/LiveChatPaidSticker.js @@ -2,23 +2,27 @@ import Parser from '../../../index'; import NavigationEndpoint from '../../NavigationEndpoint'; import Thumbnail from '../../misc/Thumbnail'; import Text from '../../misc/Text'; - import { YTNode } from '../../../helpers'; + class LiveChatPaidSticker extends YTNode { static type = 'LiveChatPaidSticker'; + constructor(data) { super(); this.id = data.id; + this.author = { id: data.authorExternalChannelId, name: new Text(data.authorName), thumbnails: Thumbnail.fromResponse(data.authorPhoto), badges: Parser.parse(data.authorBadges) }; + this.sticker = Thumbnail.fromResponse(data.sticker); this.purchase_amount = new Text(data.purchaseAmountText).toString(); this.context_menu = new NavigationEndpoint(data.contextMenuEndpoint); this.timestamp = Math.floor(parseInt(data.timestampUsec) / 1000); } } -export default LiveChatPaidSticker; + +export default LiveChatPaidSticker; \ No newline at end of file diff --git a/src/parser/classes/livechat/items/LiveChatPlaceholderItem.js b/src/parser/classes/livechat/items/LiveChatPlaceholderItem.js index 011a2315..49993edc 100644 --- a/src/parser/classes/livechat/items/LiveChatPlaceholderItem.js +++ b/src/parser/classes/livechat/items/LiveChatPlaceholderItem.js @@ -1,11 +1,13 @@ - import { YTNode } from '../../../helpers'; + class LiveChatPlaceholderItem extends YTNode { static type = 'LiveChatPlaceholderItem'; + constructor(data) { super(); this.id = data.id; this.timestamp = Math.floor(parseInt(data.timestampUsec) / 1000); } } -export default LiveChatPlaceholderItem; + +export default LiveChatPlaceholderItem; \ No newline at end of file diff --git a/src/parser/classes/livechat/items/LiveChatTextMessage.js b/src/parser/classes/livechat/items/LiveChatTextMessage.js index b50943c7..50141ee9 100644 --- a/src/parser/classes/livechat/items/LiveChatTextMessage.js +++ b/src/parser/classes/livechat/items/LiveChatTextMessage.js @@ -2,19 +2,23 @@ import Text from '../../misc/Text'; import Thumbnail from '../../misc/Thumbnail'; import NavigationEndpoint from '../../NavigationEndpoint'; import Parser from '../../../index'; - import { YTNode } from '../../../helpers'; + class LiveChatTextMessage extends YTNode { static type = 'LiveChatTextMessage'; + constructor(data) { super(); this.message = new Text(data.message); + this.author = { id: data.authorExternalChannelId, name: new Text(data.authorName), thumbnails: Thumbnail.fromResponse(data.authorPhoto) }; + const badges = Parser.parse(data.authorBadges); + this.author.badges = badges; this.author.is_moderator = badges?.some((badge) => badge.icon_type == 'MODERATOR') || null; this.author.is_verified = badges?.some((badge) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED') || null; @@ -24,4 +28,5 @@ class LiveChatTextMessage extends YTNode { this.id = data.id; } } -export default LiveChatTextMessage; + +export default LiveChatTextMessage; \ No newline at end of file diff --git a/src/parser/classes/livechat/items/LiveChatTickerPaidMessageItem.js b/src/parser/classes/livechat/items/LiveChatTickerPaidMessageItem.js index 2a7de4b7..047b6f85 100644 --- a/src/parser/classes/livechat/items/LiveChatTickerPaidMessageItem.js +++ b/src/parser/classes/livechat/items/LiveChatTickerPaidMessageItem.js @@ -2,18 +2,22 @@ import Text from '../../misc/Text'; import Thumbnail from '../../misc/Thumbnail'; import NavigationEndpoint from '../../NavigationEndpoint'; import Parser from '../../../index'; - import { YTNode } from '../../../helpers'; + class LiveChatTickerPaidMessageItem extends YTNode { static type = 'LiveChatTickerPaidMessageItem'; + constructor(data) { super(); + this.author = { id: data.authorExternalChannelId, thumbnails: Thumbnail.fromResponse(data.authorPhoto), badges: Parser.parse(data.authorBadges) }; + const badges = Parser.parse(data.authorBadges); + this.author.badges = badges; this.author.is_moderator = badges?.some((badge) => badge.icon_type == 'MODERATOR') || null; this.author.is_verified = badges?.some((badge) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED') || null; @@ -26,4 +30,5 @@ class LiveChatTickerPaidMessageItem extends YTNode { this.id = data.id; } } -export default LiveChatTickerPaidMessageItem; + +export default LiveChatTickerPaidMessageItem; \ No newline at end of file diff --git a/src/parser/classes/livechat/items/LiveChatTickerSponsorItem.js b/src/parser/classes/livechat/items/LiveChatTickerSponsorItem.js index fc4a90d4..fec41e7c 100644 --- a/src/parser/classes/livechat/items/LiveChatTickerSponsorItem.js +++ b/src/parser/classes/livechat/items/LiveChatTickerSponsorItem.js @@ -1,21 +1,24 @@ import Text from '../../misc/Text'; import Thumbnail from '../../misc/Thumbnail'; - import { YTNode } from '../../../helpers'; + class LiveChatTickerSponsorItem extends YTNode { static type = 'LiveChatTickerSponsorItem'; + constructor(data) { super(); this.id = data.id; this.detail_text = new Text(data.detailText).toString(); + this.author = { id: data.authorExternalChannelId, name: new Text(data?.authorName), thumbnails: Thumbnail.fromResponse(data.sponsorPhoto) }; + this.duration_sec = data.durationSec; // TODO: finish this - // Console.log(data) } } -export default LiveChatTickerSponsorItem; + +export default LiveChatTickerSponsorItem; \ No newline at end of file diff --git a/src/parser/classes/livechat/items/LiveChatViewerEngagementMessage.js b/src/parser/classes/livechat/items/LiveChatViewerEngagementMessage.js index 2375bf13..dbf680de 100644 --- a/src/parser/classes/livechat/items/LiveChatViewerEngagementMessage.js +++ b/src/parser/classes/livechat/items/LiveChatViewerEngagementMessage.js @@ -3,6 +3,7 @@ import Parser from '../../../index'; class LiveChatViewerEngagementMessage extends LiveChatTextMessage { static type = 'LiveChatViewerEngagementMessage'; + constructor(data) { super(data); delete this.author; @@ -11,4 +12,5 @@ class LiveChatViewerEngagementMessage extends LiveChatTextMessage { this.action_button = Parser.parse(data.actionButton); } } -export default LiveChatViewerEngagementMessage; + +export default LiveChatViewerEngagementMessage; \ No newline at end of file diff --git a/src/parser/classes/livechat/items/PollHeader.js b/src/parser/classes/livechat/items/PollHeader.js index b877bc14..001c19f0 100644 --- a/src/parser/classes/livechat/items/PollHeader.js +++ b/src/parser/classes/livechat/items/PollHeader.js @@ -1,10 +1,11 @@ import Text from '../../misc/Text'; import Thumbnail from '../../misc/Thumbnail'; import Parser from '../../../index'; - import { YTNode } from '../../../helpers'; + class PollHeader extends YTNode { static type = 'PollHeader'; + constructor(data) { super(); this.poll_question = new Text(data.pollQuestion); @@ -14,4 +15,5 @@ class PollHeader extends YTNode { this.context_menu_button = Parser.parse(data.contextMenuButton); } } -export default PollHeader; + +export default PollHeader; \ No newline at end of file diff --git a/src/parser/classes/menus/Menu.ts b/src/parser/classes/menus/Menu.ts index 0f52e322..389598d1 100644 --- a/src/parser/classes/menus/Menu.ts +++ b/src/parser/classes/menus/Menu.ts @@ -1,21 +1,24 @@ import Parser from '../../index'; - import { YTNode } from '../../helpers'; class Menu extends YTNode { static type = 'Menu'; + items; top_level_buttons; label; + constructor(data: any) { super(); this.items = Parser.parseArray(data.items); this.top_level_buttons = Parser.parseArray(data.topLevelButtons); this.label = data.accessibility?.accessibilityData?.label || null; } + // XXX: alias for consistency get contents() { return this.items; } } -export default Menu; + +export default Menu; \ No newline at end of file diff --git a/src/parser/classes/menus/MenuNavigationItem.js b/src/parser/classes/menus/MenuNavigationItem.js index 5de4e88e..21a89393 100644 --- a/src/parser/classes/menus/MenuNavigationItem.js +++ b/src/parser/classes/menus/MenuNavigationItem.js @@ -2,8 +2,10 @@ import Button from '../Button'; class MenuNavigationItem extends Button { static type = 'MenuNavigationItem'; + constructor(data) { super(data); } } -export default MenuNavigationItem; + +export default MenuNavigationItem; \ No newline at end of file diff --git a/src/parser/classes/menus/MenuServiceItem.js b/src/parser/classes/menus/MenuServiceItem.js index 0172d2bd..6eb252c5 100644 --- a/src/parser/classes/menus/MenuServiceItem.js +++ b/src/parser/classes/menus/MenuServiceItem.js @@ -2,8 +2,10 @@ import Button from '../Button'; class MenuServiceItem extends Button { static type = 'MenuServiceItem'; + constructor(data) { super(data); } } -export default MenuServiceItem; + +export default MenuServiceItem; \ No newline at end of file diff --git a/src/parser/classes/menus/MenuServiceItemDownload.js b/src/parser/classes/menus/MenuServiceItemDownload.js index 4e6efbe3..6c93c722 100644 --- a/src/parser/classes/menus/MenuServiceItemDownload.js +++ b/src/parser/classes/menus/MenuServiceItemDownload.js @@ -1,13 +1,14 @@ import NavigationEndpoint from '../NavigationEndpoint'; - import { YTNode } from '../../helpers'; class MenuServiceItemDownload extends YTNode { static type = 'MenuServiceItemDownload'; + constructor(data) { super(); this.has_separator = data.hasSeparator; this.endpoint = new NavigationEndpoint(data.navigationEndpoint || data.serviceEndpoint); } } -export default MenuServiceItemDownload; + +export default MenuServiceItemDownload; \ No newline at end of file diff --git a/src/parser/classes/menus/MultiPageMenu.js b/src/parser/classes/menus/MultiPageMenu.js index 4473d91e..1ba51b06 100644 --- a/src/parser/classes/menus/MultiPageMenu.js +++ b/src/parser/classes/menus/MultiPageMenu.js @@ -1,9 +1,9 @@ import Parser from '../../index'; - import { YTNode } from '../../helpers'; class MultiPageMenu extends YTNode { static type = 'MultiPageMenu'; + constructor(data) { super(); this.header = Parser.parse(data.header); @@ -11,4 +11,5 @@ class MultiPageMenu extends YTNode { this.style = data.style; } } -export default MultiPageMenu; + +export default MultiPageMenu; \ No newline at end of file diff --git a/src/parser/classes/menus/MultiPageMenuNotificationSection.js b/src/parser/classes/menus/MultiPageMenuNotificationSection.js index 172e67b8..43f4900f 100644 --- a/src/parser/classes/menus/MultiPageMenuNotificationSection.js +++ b/src/parser/classes/menus/MultiPageMenuNotificationSection.js @@ -1,15 +1,17 @@ import Parser from '../../index'; - import { YTNode } from '../../helpers'; class MultiPageMenuNotificationSection extends YTNode { static type = 'MultiPageMenuNotificationSection'; + constructor(data) { super(); this.items = Parser.parse(data.items); } + get contents() { return this.items; } } -export default MultiPageMenuNotificationSection; + +export default MultiPageMenuNotificationSection; \ No newline at end of file diff --git a/src/parser/classes/menus/SimpleMenuHeader.js b/src/parser/classes/menus/SimpleMenuHeader.js index 720a49bd..5eddedfb 100644 --- a/src/parser/classes/menus/SimpleMenuHeader.js +++ b/src/parser/classes/menus/SimpleMenuHeader.js @@ -1,14 +1,15 @@ import Parser from '../../index'; import Text from '../misc/Text'; - import { YTNode } from '../../helpers'; class SimpleMenuHeader extends YTNode { static type = 'SimpleMenuHeader'; + constructor(data) { super(); this.title = new Text(data.title); this.buttons = Parser.parse(data.buttons); } } -export default SimpleMenuHeader; + +export default SimpleMenuHeader; \ No newline at end of file diff --git a/src/parser/classes/misc/Author.js b/src/parser/classes/misc/Author.js index 4917b11d..ad31b645 100644 --- a/src/parser/classes/misc/Author.js +++ b/src/parser/classes/misc/Author.js @@ -5,23 +5,28 @@ import Constants from '../../../utils/Constants'; class Author { #nav_text; + constructor(item, badges, thumbs) { this.#nav_text = new NavigatableText(item); + this.id = - this.#nav_text.runs?.[0].endpoint.browse?.id || - this.#nav_text.endpoint?.browse?.id || 'N/A'; + this.#nav_text.runs?.[0].endpoint.browse?.id || + this.#nav_text.endpoint?.browse?.id || 'N/A'; + this.name = this.#nav_text.text || 'N/A'; this.thumbnails = thumbs ? Thumbnail.fromResponse(thumbs) : []; this.endpoint = this.#nav_text.runs?.[0].endpoint || this.#nav_text.endpoint; this.badges = Array.isArray(badges) ? Parser.parseArray(badges) : []; this.is_verified = this.badges?.some((badge) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED') || null; this.is_verified_artist = this.badges?.some((badge) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED_ARTIST') || null; + this.url = - this.#nav_text.runs?.[0].endpoint.browse && - `${Constants.URLS.YT_BASE}${this.#nav_text.runs[0].endpoint.browse?.base_url || `/u/${this.#nav_text.runs[0].endpoint.browse?.id}`}` || - `${Constants.URLS.YT_BASE}${this.#nav_text.endpoint?.browse?.base_url || `/u/${this.#nav_text.endpoint?.browse?.id}`}` || - null; + this.#nav_text.runs?.[0].endpoint.browse && + `${Constants.URLS.YT_BASE}${this.#nav_text.runs[0].endpoint.browse?.base_url || `/u/${this.#nav_text.runs[0].endpoint.browse?.id}`}` || + `${Constants.URLS.YT_BASE}${this.#nav_text.endpoint?.browse?.base_url || `/u/${this.#nav_text.endpoint?.browse?.id}`}` || + null; } + /** * @type {Thumbnail | undefined} */ @@ -29,4 +34,5 @@ class Author { return this.thumbnails[0]; } } -export default Author; + +export default Author; \ No newline at end of file diff --git a/src/parser/classes/misc/Format.js b/src/parser/classes/misc/Format.js index 10674ff4..f0cb336c 100644 --- a/src/parser/classes/misc/Format.js +++ b/src/parser/classes/misc/Format.js @@ -1,4 +1,3 @@ - class Format { constructor(data) { this.itag = data.itag; @@ -7,14 +6,17 @@ class Format { this.average_bitrate = data.averageBitrate; this.width = data.width || null; this.height = data.height || null; + this.init_range = data.initRange ? { start: parseInt(data.initRange.start), end: parseInt(data.initRange.end) } : undefined; + this.index_range = data.indexRange ? { start: parseInt(data.indexRange.start), end: parseInt(data.indexRange.end) } : undefined; + this.last_modified = new Date(Math.floor(parseInt(data.lastModified) / 1000)); this.content_length = parseInt(data.contentLength); this.quality = data.quality; @@ -31,6 +33,7 @@ class Format { this.has_audio = !!data.audioBitrate || !!data.audioQuality; this.has_video = !!data.qualityLabel; } + /** * Decipher the streaming url of the format. * @@ -41,4 +44,5 @@ class Format { return player.decipher(this.url, this.signature_cipher, this.cipher); } } -export default Format; + +export default Format; \ No newline at end of file diff --git a/src/parser/classes/misc/NavigatableText.js b/src/parser/classes/misc/NavigatableText.js index c9922cb4..450cf44a 100644 --- a/src/parser/classes/misc/NavigatableText.js +++ b/src/parser/classes/misc/NavigatableText.js @@ -1,21 +1,26 @@ import Text from './Text'; import NavigationEndpoint from '../NavigationEndpoint'; + class NavigatableText extends Text { static type = 'NavigatableText'; + endpoint; + constructor(node) { super(node); // TODO: is this needed? Text now supports this itself this.endpoint = - node.runs?.[0]?.navigationEndpoint ? - new NavigationEndpoint(node.runs[0].navigationEndpoint) : - node.navigationEndpoint ? - new NavigationEndpoint(node.navigationEndpoint) : - node.titleNavigationEndpoint ? - new NavigationEndpoint(node.titleNavigationEndpoint) : null; + node.runs?.[0]?.navigationEndpoint ? + new NavigationEndpoint(node.runs[0].navigationEndpoint) : + node.navigationEndpoint ? + new NavigationEndpoint(node.navigationEndpoint) : + node.titleNavigationEndpoint ? + new NavigationEndpoint(node.titleNavigationEndpoint) : null; } + toJSON() { return this; } } -export default NavigatableText; + +export default NavigatableText; \ No newline at end of file diff --git a/src/parser/classes/misc/PlaylistAuthor.js b/src/parser/classes/misc/PlaylistAuthor.js index f48f6a3e..ada0a0c6 100644 --- a/src/parser/classes/misc/PlaylistAuthor.js +++ b/src/parser/classes/misc/PlaylistAuthor.js @@ -1,4 +1,5 @@ import Author from './Author'; + class PlaylistAuthor extends Author { constructor(data) { super(data); @@ -7,4 +8,5 @@ class PlaylistAuthor extends Author { delete this.is_verified_artist; } } -export default PlaylistAuthor; + +export default PlaylistAuthor; \ No newline at end of file diff --git a/src/parser/classes/misc/Text.ts b/src/parser/classes/misc/Text.ts index 6a0b643b..b734352c 100644 --- a/src/parser/classes/misc/Text.ts +++ b/src/parser/classes/misc/Text.ts @@ -4,6 +4,7 @@ import EmojiRun from '../EmojiRun'; class Text { text: string; runs; + constructor(data: any) { if (data?.hasOwnProperty('runs') && Array.isArray(data.runs)) { this.runs = (data.runs as any[]).map((run: any) => run.emoji ? @@ -15,8 +16,10 @@ class Text { this.text = data?.simpleText || 'N/A'; } } + toString() { return this.text; } } -export default Text; + +export default Text; \ No newline at end of file diff --git a/src/parser/classes/misc/TextRun.js b/src/parser/classes/misc/TextRun.js index a7c54277..0ab7ee7e 100644 --- a/src/parser/classes/misc/TextRun.js +++ b/src/parser/classes/misc/TextRun.js @@ -6,4 +6,5 @@ class TextRun { this.endpoint = data.navigationEndpoint ? new NavigationEndpoint(data.navigationEndpoint) : undefined; } } -export default TextRun; + +export default TextRun; \ No newline at end of file diff --git a/src/parser/classes/misc/Thumbnail.js b/src/parser/classes/misc/Thumbnail.js index e76d0b76..707dbdc7 100644 --- a/src/parser/classes/misc/Thumbnail.js +++ b/src/parser/classes/misc/Thumbnail.js @@ -1,4 +1,3 @@ - class Thumbnail { /** * @type {string} @@ -12,11 +11,13 @@ class Thumbnail { * @type {number} */ height; + constructor({ url, width, height }) { this.url = url; this.width = width; this.height = height; } + /** * Get thumbnails from response object * @@ -29,4 +30,5 @@ class Thumbnail { return data.thumbnails.map((x) => new Thumbnail(x)).sort((a, b) => b.width - a.width); } } -export default Thumbnail; + +export default Thumbnail; \ No newline at end of file diff --git a/src/parser/classes/misc/VideoDetails.js b/src/parser/classes/misc/VideoDetails.js index 865c3f57..290c4d88 100644 --- a/src/parser/classes/misc/VideoDetails.js +++ b/src/parser/classes/misc/VideoDetails.js @@ -25,6 +25,7 @@ class VideoDetails { * @type {string} */ author; + constructor(data) { this.id = data.videoId; this.channel_id = data.channelId; @@ -42,4 +43,5 @@ class VideoDetails { this.is_crawlable = !!data.isCrawlable; } } -export default VideoDetails; + +export default VideoDetails; \ No newline at end of file diff --git a/src/parser/helpers.ts b/src/parser/helpers.ts index 41fa114d..59b2758c 100644 --- a/src/parser/helpers.ts +++ b/src/parser/helpers.ts @@ -1,12 +1,15 @@ import { deepCompare, ParsingError } from '../utils/Utils'; const isObserved = Symbol('ObservedArray.isObserved'); + export class YTNode { static readonly type: string = 'YTNode'; readonly type: string; + constructor() { this.type = (this.constructor as YTNodeConstructor).type; } + /** * Check if the node is of the given type. * @param type - The type to check @@ -15,6 +18,7 @@ export class YTNode { #is(type: YTNodeConstructor): this is T { return this.type === type.type; } + /** * Check if the node is of the given type. * @param types - The type to check @@ -23,6 +27,7 @@ export class YTNode { is[]>(...types: K): this is InstanceType { return types.some((type) => this.#is(type)); } + /** * Cast to one of the given types. */ @@ -32,6 +37,7 @@ export class YTNode { } return this; } + /** * Check for a key without asserting the type. * @param key - The key to check @@ -40,6 +46,7 @@ export class YTNode { hasKey(key: T): this is this & { [k in T]: R } { return Reflect.has(this, key); } + /** * Assert that the node has the given key and return it. * @param key - The key to check @@ -56,6 +63,7 @@ export class YTNode { export class Maybe { #value; + constructor (value: any) { this.#value = value; } @@ -311,6 +319,7 @@ export interface YTNodeConstructor { */ export class SuperParsedResult { #result; + constructor(result: T | ObservedArray | null) { this.#result = result; } @@ -386,9 +395,11 @@ export function observe(obj: Array) { }) ); } + if (prop == isObserved) { return true; } + if (prop == 'getAll') { return (rule: object, del_items: boolean) => ( target.filter((obj, index) => { @@ -400,6 +411,7 @@ export function observe(obj: Array) { }) ); } + if (prop == 'filterType') { return (...types: YTNodeConstructor[]) => { return observe(target.filter((node: YTNode) => { @@ -410,6 +422,7 @@ export function observe(obj: Array) { })); }; } + if (prop == 'firstOfType') { return (...types: YTNodeConstructor[]) => { return target.find((node: YTNode) => { @@ -419,6 +432,7 @@ export function observe(obj: Array) { }); }; } + if (prop == 'as') { return (...types: YTNodeConstructor[]) => { return observe(target.map((node: YTNode) => { @@ -428,9 +442,11 @@ export function observe(obj: Array) { })); }; } + if (prop == 'remove') { return (index: number): any => target.splice(index, 1); } + return Reflect.get(target, prop); } }) as ObservedArray; @@ -442,4 +458,4 @@ export class Memo extends Map { return observe(type.flatMap((type) => (this.get(type.type) || []) as T[])); return observe((this.get(type.type) || []) as T[]); } -} +} \ No newline at end of file diff --git a/src/parser/index.ts b/src/parser/index.ts index c3bd5767..4aadc937 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -1,12 +1,14 @@ -import { InnertubeError, ParsingError } from '../utils/Utils'; import Format from './classes/misc/Format'; import VideoDetails from './classes/misc/VideoDetails'; import GetParserByName from './map'; -import package_json from '../../package.json'; import Endscreen from './classes/Endscreen'; import CardCollection from './classes/CardCollection'; + +import { InnertubeError, ParsingError } from '../utils/Utils'; import { YTNode, YTNodeConstructor, SuperParsedResult, ObservedArray, observe, Memo } from './helpers'; +import package_json from '../../package.json'; + export class AppendContinuationItemsAction extends YTNode { static readonly type = 'appendContinuationItemsAction'; @@ -17,38 +19,49 @@ export class AppendContinuationItemsAction extends YTNode { this.contents = Parser.parse(data.continuationItems, true); } } + export class ReloadContinuationItemsCommand extends YTNode { static readonly type = 'reloadContinuationItemsCommand'; + target_id: string; contents: ObservedArray | null; + constructor(data: any) { super(); this.target_id = data.targetId; this.contents = Parser.parse(data.continuationItems, true); } } + export class SectionListContinuation extends YTNode { static readonly type = 'sectionListContinuation'; + continuation: string; contents: ObservedArray | null; + constructor(data: any) { super(); this.contents = Parser.parse(data.contents, true); this.continuation = data.continuations[0].nextContinuationData.continuation; } } + export class TimedContinuation extends YTNode { static readonly type = 'timedContinuationData'; - timeout_ms: number; // TODO: is this a number or a string? + + timeout_ms: number; token: string; + constructor(data: any) { super(); this.timeout_ms = data.timeoutMs || data.timeUntilLastMessageMsec; this.token = data.continuation; } } + export class LiveChatContinuation extends YTNode { static readonly type = 'liveChatContinuation'; + actions: ObservedArray; action_panel: YTNode | null; item_list: YTNode | null; @@ -58,17 +71,20 @@ export class LiveChatContinuation extends YTNode { emojis: any[] | null; // TODO: give this an actual type continuation: TimedContinuation; viewer_name: string; + constructor(data: any) { super(); this.actions = Parser.parse(data.actions?.map((action: any) => { delete action.clickTrackingParams; return action; }), true) || observe([]); + this.action_panel = Parser.parseItem(data.actionPanel); this.item_list = Parser.parseItem(data.itemList); this.header = Parser.parseItem(data.header); this.participants_list = Parser.parseItem(data.participantsList); this.popout_message = Parser.parseItem(data.popoutMessage); + this.emojis = data.emojis?.map((emoji: any) => ({ emoji_id: emoji.emojiId, shortcuts: emoji.shortcuts, @@ -76,21 +92,28 @@ export class LiveChatContinuation extends YTNode { image: emoji.image, is_custom_emoji: emoji.isCustomEmoji })) || null; - this.continuation = new TimedContinuation(data.continuations?.[0].timedContinuationData || - data.continuations?.[0].invalidationContinuationData || - data.continuations?.[0].liveChatReplayContinuationData); + + this.continuation = new TimedContinuation( + data.continuations?.[0].timedContinuationData || + data.continuations?.[0].invalidationContinuationData || + data.continuations?.[0].liveChatReplayContinuationData + ); + this.viewer_name = data.viewerName; } } export default class Parser { static #memo: Memo | null = null; + static #clearMemo() { Parser.#memo = null; } + static #createMemo() { Parser.#memo = new Memo(); } + static #addToMemo(classname: string, result: YTNode) { if (!Parser.#memo) return; @@ -101,11 +124,13 @@ export default class Parser { list.push(result); } + static #getMemo() { if (!Parser.#memo) throw new Error('Parser#getMemo() called before Parser#createMemo()'); return Parser.#memo; } + /** * Parses InnerTube response. */ @@ -117,22 +142,27 @@ export default class Parser { const contents_memo = this.#getMemo(); // End of memoization this.#clearMemo(); + this.#createMemo(); const on_response_received_actions = data.onResponseReceivedActions ? Parser.parseRR(data.onResponseReceivedActions) : null; const on_response_received_actions_memo = this.#getMemo(); this.#clearMemo(); + this.#createMemo(); const on_response_received_endpoints = data.onResponseReceivedEndpoints ? Parser.parseRR(data.onResponseReceivedEndpoints) : null; const on_response_received_endpoints_memo = this.#getMemo(); this.#clearMemo(); + this.#createMemo(); const on_response_received_commands = data.onResponseReceivedCommands ? Parser.parseRR(data.onResponseReceivedCommands) : null; const on_response_received_commands_memo = this.#getMemo(); this.#clearMemo(); + this.#createMemo(); const actions = data.actions ? Parser.parseActions(data.actions) : null; const actions_memo = this.#getMemo(); this.#clearMemo(); + return { actions, actions_memo, @@ -177,16 +207,19 @@ export default class Parser { cards: Parser.parseItem(data.cards, CardCollection) }; } + static parseC(data: any) { if (data.timedContinuationData) return new TimedContinuation(data.timedContinuationData); } + static parseLC(data: any) { if (data.sectionListContinuation) return new SectionListContinuation(data.sectionListContinuation); if (data.liveChatContinuation) return new LiveChatContinuation(data.liveChatContinuation); } + static parseRR(actions: any[]) { return observe(actions.map((action: any) => { if (action.reloadContinuationItemsCommand) @@ -195,6 +228,7 @@ export default class Parser { return new AppendContinuationItemsAction(action.appendContinuationItemsAction); }).filter((item) => item) as (ReloadContinuationItemsCommand | AppendContinuationItemsAction)[]); } + static parseActions(data: any) { if (Array.isArray(data)) { return Parser.parse(data.map((action) => { @@ -204,17 +238,21 @@ export default class Parser { } return new SuperParsedResult(Parser.parseItem(data)); } + static parseFormats(formats: any[]) { return formats?.map((format) => new Format(format)) || []; } static parseItem(data: any, validTypes?: YTNodeConstructor | YTNodeConstructor[]) { if (!data) return null; + const keys = Object.keys(data); const classname = this.sanitizeClassName(keys[0]); + if (!this.shouldIgnore(classname)) { try { const TargetClass = GetParserByName(classname); + if (validTypes) { if (Array.isArray(validTypes)) { if (!validTypes.some((type) => type.type === TargetClass.type)) @@ -222,26 +260,31 @@ export default class Parser { } else if (TargetClass.type !== validTypes.type) throw new ParsingError(`Type mismatch, got ${classname} but expected ${validTypes.type}`); } + const result = new TargetClass(data[keys[0]]); this.#addToMemo(classname, result); + return result as T; } catch (err) { this.formatError({ classname, classdata: data[keys[0]], err }); return null; } } + return null; } static parseArray(data: any[], validTypes?: YTNodeConstructor | YTNodeConstructor[]) { if (Array.isArray(data)) { const results: T[] = []; + for (const item of data) { const result = this.parseItem(item, validTypes); if (result) { results.push(result); } } + return observe(results); } else if (!data) { return observe([] as T[]); @@ -251,41 +294,56 @@ export default class Parser { static parse(data: any, requireArray: true, validTypes?: YTNodeConstructor | YTNodeConstructor[]) : ObservedArray | null; static parse(data: any, requireArray?: false | undefined, validTypes?: YTNodeConstructor | YTNodeConstructor[]) : SuperParsedResult; + /** - * Parses the `contents` property of the response. + * Parses the `contents` property of the response as well as its nodes. */ static parse(data: any, requireArray?: boolean, validTypes?: YTNodeConstructor | YTNodeConstructor[]) { - if (!data) - return null; + if (!data) return null; + if (Array.isArray(data)) { const results: T[] = []; + for (const item of data) { const result = this.parseItem(item, validTypes); if (result) { results.push(result); } } + const res = observe(results); + return requireArray ? res : new SuperParsedResult(observe(results)); } else if (requireArray) { throw new ParsingError('Expected array but got a single item'); } + return new SuperParsedResult(this.parseItem(data, validTypes)); } static formatError({ classname, classdata, err }: { classname: string, classdata: any, err: any }) { if (err.code == 'MODULE_NOT_FOUND') { - return console.warn(new InnertubeError(`${classname} not found!\n` + - `This is a bug, please report it at ${package_json.bugs.url}`, classdata)); + return console.warn( + new InnertubeError( + `${classname} not found!\n` + + `This is a bug, please report it at ${package_json.bugs.url}`, classdata) + ); } - console.warn(new InnertubeError(`Something went wrong at ${classname}!\n` + - `This is a bug, please report it at ${package_json.bugs.url}`, { stack: err.stack })); + + console.warn( + new InnertubeError( + `Something went wrong at ${classname}!\n` + + `This is a bug, please report it at ${package_json.bugs.url}`, { stack: err.stack } + ) + ); } + static sanitizeClassName(input: string) { return (input.charAt(0).toUpperCase() + input.slice(1)) .replace(/Renderer|Model/g, '') .replace(/Radio/g, 'Mix').trim(); } + static ignore_list = new Set([ 'DisplayAd', 'SearchPyv', @@ -295,9 +353,10 @@ export default class Parser { 'RunAttestationCommand', 'StatementBanner' ]); + static shouldIgnore(classname: string) { return this.ignore_list.has(classname); } } -export type ParsedResponse = ReturnType; +export type ParsedResponse = ReturnType; \ No newline at end of file diff --git a/src/parser/youtube/Analytics.js b/src/parser/youtube/Analytics.js index d48fa075..48bb60de 100644 --- a/src/parser/youtube/Analytics.js +++ b/src/parser/youtube/Analytics.js @@ -1,8 +1,8 @@ import Parser from '../index'; -/** @namespace */ class Analytics { #page; + /** * @param {object} response - API response. */ @@ -10,6 +10,7 @@ class Analytics { this.#page = Parser.parseResponse(response); this.sections = this.#page.contents_memo.get('Element'); } + get page() { return this.#page; } diff --git a/src/parser/youtube/Channel.js b/src/parser/youtube/Channel.js index 1ee80637..456416b8 100644 --- a/src/parser/youtube/Channel.js +++ b/src/parser/youtube/Channel.js @@ -2,6 +2,7 @@ import TabbedFeed from '../../core/TabbedFeed'; class Channel extends TabbedFeed { #tab; + constructor(actions, data, already_parsed = false) { super(actions, data, already_parsed); this.header = { @@ -12,32 +13,41 @@ class Channel extends TabbedFeed { mobile_banner: this.page.header.mobile_banner, header_links: this.page.header.header_links }; + this.metadata = { ...this.page.metadata, ...this.page.microformat }; this.sponsor_button = this.page.header.sponsor_button || null; this.subscribe_button = this.page.header.subscribe_button || null; + const tab = this.page.contents.tabs.get({ selected: true }); + this.current_tab = tab; } + async getVideos() { const tab = await this.getTab('Videos'); return new Channel(this.actions, tab.page, true); } + async getPlaylists() { const tab = await this.getTab('Playlists'); return new Channel(this.actions, tab.page, true); } + async getHome() { const tab = await this.getTab('Home'); return new Channel(this.actions, tab.page, true); } + async getCommunity() { const tab = await this.getTab('Community'); return new Channel(this.actions, tab.page, true); } + async getChannels() { const tab = await this.getTab('Channels'); return new Channel(this.actions, tab.page, true); } + /** * Retrieves the channel about page. * Note that this does not return a new {@link Channel} object. @@ -49,4 +59,5 @@ class Channel extends TabbedFeed { return tab.memo.get('ChannelAboutFullMetadata')?.[0]; } } -export default Channel; + +export default Channel; \ No newline at end of file diff --git a/src/parser/youtube/Comments.ts b/src/parser/youtube/Comments.ts index 85427e4d..9a46d784 100644 --- a/src/parser/youtube/Comments.ts +++ b/src/parser/youtube/Comments.ts @@ -1,64 +1,82 @@ -import Parser, { ParsedResponse } from '../index'; -import { InnertubeError } from '../../utils/Utils'; -import Actions from '../../core/Actions'; import CommentsHeader from '../classes/comments/CommentsHeader'; +import CommentSimplebox from '../classes/comments/CommentSimplebox'; import CommentThread from '../classes/comments/CommentThread'; import ContinuationItem from '../classes/ContinuationItem'; import NavigationEndpoint from '../classes/NavigationEndpoint'; + +import Actions from '../../core/Actions'; +import { InnertubeError } from '../../utils/Utils'; +import Parser, { ParsedResponse } from '../index'; + class Comments { #page: ParsedResponse; #actions; #continuation; header; contents; + constructor(actions: Actions, data: any, already_parsed = false) { this.#page = already_parsed ? data : Parser.parseResponse(data); this.#actions = actions; + const contents = this.#page.on_response_received_endpoints; this.header = contents?.[0].contents?.get({ type: 'CommentsHeader' })?.as(CommentsHeader); + // TODO: validate this const threads: CommentThread[] = contents?.[1].contents?.getAll({ type: 'CommentThread' }) as CommentThread[]; + this.contents = threads.map((thread) => { thread.comment?.setActions(this.#actions); thread.setActions(this.#actions); return thread; }); + this.#continuation = contents?.[1].contents?.get({ type: 'ContinuationItem' })?.as(ContinuationItem); } + /** * Creates a top-level comment. */ - async comment(text: string) { - const button = this.header?.create_renderer?.key('submit_button').node(); + async createComment(text: string) { + const button = this.header?.create_renderer?.as(CommentSimplebox).submit_button.item(); + const payload = { params: { commentText: text }, parse: false }; + const response = await button?.key('endpoint').nodeOfType(NavigationEndpoint).callTest(this.#actions, payload); + return response; } + /** * Retrieves next batch of comments. */ async getContinuation() { if (!this.#continuation) throw new InnertubeError('Continuation not found'); + const data = await this.#continuation.endpoint.callTest(this.#actions); + // Copy the previous page so we can keep the header. const page = Object.assign({}, this.#page); + + if (!page.on_response_received_endpoints || !data.on_response_received_endpoints) + throw new InnertubeError('Invalid reponse format, missing on_response_received_endpoints'); + // Remove previous items and append the continuation. - if (!page.on_response_received_endpoints) - throw new InnertubeError('Invalid reponse format, missing on_response_received_endpoints'); page.on_response_received_endpoints.pop(); - if (!data.on_response_received_endpoints) - throw new InnertubeError('Invalid reponse format, missing on_response_received_endpoints'); page.on_response_received_endpoints.push(data.on_response_received_endpoints[0]); + return new Comments(this.#actions, page, true); } + get page() { return this.#page; } } -export default Comments; + +export default Comments; \ No newline at end of file diff --git a/src/parser/youtube/History.js b/src/parser/youtube/History.js index a7ab8629..f099655f 100644 --- a/src/parser/youtube/History.js +++ b/src/parser/youtube/History.js @@ -1,7 +1,6 @@ import Feed from '../../core/Feed'; -// TODO: make filter actions usable -/** @namespace */ +// TODO: make feed actions usable class History extends Feed { /** * @param {import('../../core/Actions').default} actions @@ -15,9 +14,9 @@ class History extends Feed { /** @type {import('../classes/BrowseFeedActions')[]} */ this.feed_actions = this.memo.get('BrowseFeedActions')?.[0] || []; } + /** * Retrieves next batch of contents. - * * @returns {Promise.} */ async getContinuation() { @@ -25,4 +24,5 @@ class History extends Feed { return new History(this.actions, continuation, true); } } -export default History; + +export default History; \ No newline at end of file diff --git a/src/parser/youtube/Library.js b/src/parser/youtube/Library.js index 566f9ba4..701a1f9b 100644 --- a/src/parser/youtube/Library.js +++ b/src/parser/youtube/Library.js @@ -4,10 +4,10 @@ import Playlist from './Playlist'; import Feed from '../../core/Feed'; import { observe } from '../helpers'; -/** @namespace */ class Library { #actions; #page; + /** * @param {object} response - API response. * @param {import('../../core/Actions').default} actions @@ -15,11 +15,15 @@ class Library { 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: 'ProfileColumnStats' }).items; const user_info = this.#page.contents.secondary_contents.items.get({ type: 'ProfileColumnUserInfo' }); + this.profile = { stats, user_info }; + /** @type {{ type: string, title: import('../classes/misc/Text'), contents: object[], getAll: Promise. }[] } */ this.sections = observe(shelves.map((shelf) => ({ type: shelf.icon_type, @@ -28,11 +32,14 @@ class Library { 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 'LIKE': case 'WATCH_LATER': @@ -44,23 +51,30 @@ class Library { default: } } + get history() { return this.sections.get({ type: 'WATCH_HISTORY' }); } + get watch_later() { return this.sections.get({ type: 'WATCH_LATER' }); } + get liked_videos() { return this.sections.get({ type: 'LIKE' }); } + get playlists() { return this.sections.get({ type: 'PLAYLISTS' }); } + get clips() { return this.sections.get({ type: 'CONTENT_CUT' }); } + get page() { return this.#page; } } -export default Library; + +export default Library; \ No newline at end of file diff --git a/src/parser/youtube/LiveChat.ts b/src/parser/youtube/LiveChat.ts index c2b13c5e..c74981ef 100644 --- a/src/parser/youtube/LiveChat.ts +++ b/src/parser/youtube/LiveChat.ts @@ -1,24 +1,29 @@ import Parser, { LiveChatContinuation } from '../index'; import EventEmitter from '../../utils/EventEmitterLike'; import VideoInfo from './VideoInfo'; -import { InnertubeError } from '../../utils/Utils'; + import UpdateTitleAction from '../classes/livechat/UpdateTitleAction'; import UpdateDescriptionAction from '../classes/livechat/UpdateDescriptionAction'; import UpdateViewershipAction from '../classes/livechat/UpdateViewershipAction'; import UpdateDateTextAction from '../classes/livechat/UpdateDateTextAction'; -import { ObservedArray, YTNode } from '../helpers'; import UpdateToggleButtonTextAction from '../classes/livechat/UpdateToggleButtonTextAction'; import AddChatItemAction from '../classes/livechat/AddChatItemAction'; +import { InnertubeError } from '../../utils/Utils'; +import { ObservedArray, YTNode } from '../helpers'; + class LiveChat extends EventEmitter { #actions; #video_info; #continuation; #mcontinuation?: string; + #lc_polling_interval_ms = 1000; #md_polling_interval_ms = 5000; + initial_info?: LiveChatContinuation; live_metadata; + metadata?: { title: UpdateTitleAction | undefined; description: UpdateDescriptionAction | undefined; @@ -26,16 +31,18 @@ class LiveChat extends EventEmitter { likes: UpdateToggleButtonTextAction | undefined; date: UpdateDateTextAction | undefined; }; + running = false; is_replay = false; + constructor(video_info: VideoInfo) { super(); - if (!video_info.livechat) - throw new InnertubeError('Video has no livechat'); + this.#video_info = video_info; this.#actions = video_info.actions; - this.#continuation = video_info.livechat.continuation; - this.is_replay = video_info.livechat.is_replay; + this.#continuation = video_info.livechat?.continuation || undefined; + this.is_replay = video_info.livechat?.is_replay || false; + this.live_metadata = { title: null as UpdateTitleAction | null, description: null as UpdateDescriptionAction | null, @@ -44,6 +51,7 @@ class LiveChat extends EventEmitter { date: null as UpdateDateTextAction | null }; } + start() { if (!this.running) { this.running = true; @@ -51,21 +59,26 @@ class LiveChat extends EventEmitter { this.#pollMetadata(); } } + stop() { this.running = false; } + #pollLivechat() { const lc_poller = setTimeout(() => { (async () => { const endpoint = this.is_replay ? 'live_chat/get_live_chat_replay' : 'live_chat/get_live_chat'; const response = await this.#actions.livechat(endpoint, { ctoken: this.#continuation }); + const data = Parser.parseResponse(response.data); const contents = data.continuation_contents; - if (!(contents instanceof LiveChatContinuation)) { + + if (!(contents instanceof LiveChatContinuation)) throw new InnertubeError('Continuation is not a LiveChatContinuation'); - } + this.#continuation = contents.continuation.token; this.#lc_polling_interval_ms = contents.continuation.timeout_ms; + // Header only exists in the first request if (contents.header) { this.initial_info = contents; @@ -73,7 +86,9 @@ class LiveChat extends EventEmitter { } else { await this.#emitSmoothedActions(contents.actions); } + clearTimeout(lc_poller); + this.running && this.#pollLivechat(); })().catch((err) => Promise.reject(err)); }, this.#lc_polling_interval_ms); @@ -84,16 +99,23 @@ class LiveChat extends EventEmitter { */ async #emitSmoothedActions(actions: ObservedArray) { const base = 1E4; + let delay = actions.length < base / 80 ? 1 : 0; - const emit_delay_ms = delay == 1 ? (delay = base / actions.length, - delay *= Math.random() + 0.5, - delay = Math.min(1E3, delay), - delay = Math.max(80, delay)) : delay = 80; + + const emit_delay_ms = + delay == 1 ? ( + delay = base / actions.length, + delay *= Math.random() + 0.5, + delay = Math.min(1E3, delay), + delay = Math.max(80, delay) + ) : delay = 80; + for (const action of actions) { await this.#wait(emit_delay_ms); this.emit('chat-update', action); } } + #pollMetadata() { const md_poller = setTimeout(() => { (async () => { @@ -101,13 +123,17 @@ class LiveChat extends EventEmitter { video_id: this.#video_info.basic_info.id, ctoken: undefined as string | undefined }; + if (this.#mcontinuation) { payload.ctoken = this.#mcontinuation; } + const response = await this.#actions.livechat('updated_metadata', payload); const data = Parser.parseResponse(response.data); + this.#mcontinuation = data.continuation?.token; this.#md_polling_interval_ms = data.continuation?.timeout_ms || this.#md_polling_interval_ms; + this.metadata = { title: data.actions?.array().firstOfType(UpdateTitleAction) || this.metadata?.title, description: data.actions?.array().firstOfType(UpdateDescriptionAction) || this.metadata?.description, @@ -115,12 +141,16 @@ class LiveChat extends EventEmitter { likes: data.actions?.array().firstOfType(UpdateToggleButtonTextAction) || this.metadata?.likes, date: data.actions?.array().firstOfType(UpdateDateTextAction) || this.metadata?.date }; + this.emit('metadata-update', this.metadata); + clearTimeout(md_poller); + this.running && this.#pollMetadata(); })().catch((err) => Promise.reject(err)); }, this.#md_polling_interval_ms); } + /** * Sends a message. */ @@ -132,11 +162,14 @@ class LiveChat extends EventEmitter { channel_id: this.#video_info.basic_info.channel_id } }); + const data = Parser.parseResponse(response.data); return data.actions?.array().as(AddChatItemAction); } + async #wait(ms: number) { return new Promise((resolve) => setTimeout(() => resolve(), ms)); } } -export default LiveChat; + +export default LiveChat; \ No newline at end of file diff --git a/src/parser/youtube/VideoInfo.ts b/src/parser/youtube/VideoInfo.ts index ae51ad6e..6085f33f 100644 --- a/src/parser/youtube/VideoInfo.ts +++ b/src/parser/youtube/VideoInfo.ts @@ -1,53 +1,56 @@ -import { getStringBetweenStrings, InnertubeError, streamToIterable } from '../../utils/Utils'; import Parser, { ParsedResponse } from '../index'; -import LiveChat from '../classes/LiveChat'; import Constants from '../../utils/Constants'; import Actions, { AxioslikeResponse } from '../../core/Actions'; import Player from '../../core/Player'; + import TwoColumnWatchNextResults from '../classes/TwoColumnWatchNextResults'; import VideoPrimaryInfo from '../classes/VideoPrimaryInfo'; -import MerchandiseShelf from '../classes/MerchandiseShelf'; import VideoSecondaryInfo from '../classes/VideoSecondaryInfo'; +import PlayerMicroformat from '../classes/PlayerMicroformat'; +import Format from '../classes/misc/Format'; + +import MerchandiseShelf from '../classes/MerchandiseShelf'; import RelatedChipCloud from '../classes/RelatedChipCloud'; + import ChipCloud from '../classes/ChipCloud'; import ItemSection from '../classes/ItemSection'; import PlayerOverlay from '../classes/PlayerOverlay'; import ToggleButton from '../classes/ToggleButton'; import CommentsEntryPointHeader from '../classes/comments/CommentsEntryPointHeader'; import ContinuationItem from '../classes/ContinuationItem'; + +import LiveChat from '../classes/LiveChat'; import LiveChatWrap from './LiveChat'; -import CompactVideo from '../classes/CompactVideo'; -import CompactMix from '../classes/CompactMix'; -import PlayerMicroformat from '../classes/PlayerMicroformat'; -import Format from '../classes/misc/Format'; + import { create } from 'xmlbuilder2'; import { XMLBuilder } from 'xmlbuilder2/lib/interfaces'; +import { getStringBetweenStrings, InnertubeError, streamToIterable } from '../../utils/Utils'; export type URLTransformer = (url: URL) => URL; export interface FormatOptions { - /** - * Video quality; 360p, 720p, 1080p, etc... also accepts 'best' and 'bestefficiency'. - */ - quality?: string; - /** - * Download type, can be: video, audio or video+audio - */ - type?: 'video' | 'audio' | 'video+audio'; - /** - * File format, use 'any' to download any format - */ - format?: string; + /** + * Video quality; 360p, 720p, 1080p, etc... also accepts 'best' and 'bestefficiency'. + */ + quality?: string; + /** + * Download type, can be: video, audio or video+audio + */ + type?: 'video' | 'audio' | 'video+audio'; + /** + * File format, use 'any' to download any format + */ + format?: string; } export interface DownloadOptions extends FormatOptions { - /** - * Download range, indicates which bytes should be downloaded. - */ - range?: { - start: number; - end: number; - } + /** + * Download range, indicates which bytes should be downloaded. + */ + range?: { + start: number; + end: number; + } } class VideoInfo { @@ -56,6 +59,7 @@ class VideoInfo { #player; #cpn; #watch_next_continuation; + basic_info; streaming_data; playability_status; @@ -72,6 +76,7 @@ class VideoInfo { player_overlays; comments_entry_point_header; livechat; + /** * @param data - API response. * @param cpn - Client Playback Nonce @@ -80,13 +85,18 @@ class VideoInfo { this.#actions = actions; this.#player = player; this.#cpn = cpn; + const info = Parser.parseResponse(data[0].data); const next = data?.[1]?.data ? Parser.parseResponse(data[1].data) : undefined; + this.#page = [ info, next ]; + if (info.playability_status?.status === 'ERROR') throw new InnertubeError('This video is unavailable', info.playability_status); + if (!info.microformat?.is(PlayerMicroformat)) throw new InnertubeError('Invalid microformat', info.microformat); + this.basic_info = { // This type is inferred so no need for an explicit type ...info.video_details, ...{ @@ -104,6 +114,7 @@ class VideoInfo { is_liked: undefined as boolean | undefined, is_disliked: undefined as boolean | undefined }; + this.streaming_data = info.streaming_data; this.playability_status = info.playability_status; this.annotations = info.annotations; @@ -111,87 +122,118 @@ class VideoInfo { this.endscreen = info.endscreen; this.captions = info.captions; this.cards = info.cards; + const two_col = next?.contents.item().as(TwoColumnWatchNextResults); + const results = two_col?.results; const secondary_results = two_col?.secondary_results; + if (results && secondary_results) { this.primary_info = results.get({ type: 'VideoPrimaryInfo' })?.as(VideoPrimaryInfo); this.secondary_info = results.get({ type: 'VideoSecondaryInfo' })?.as(VideoSecondaryInfo); this.merchandise = results.get({ type: 'MerchandiseShelf' })?.as(MerchandiseShelf); this.related_chip_cloud = secondary_results?.get({ type: 'RelatedChipCloud' })?.as(RelatedChipCloud)?.content.item().as(ChipCloud); + this.watch_next_feed = secondary_results?.get({ type: 'ItemSection' })?.as(ItemSection)?.contents; + if (this.watch_next_feed && Array.isArray(this.watch_next_feed)) this.#watch_next_continuation = this.watch_next_feed?.pop()?.as(ContinuationItem); + this.player_overlays = next?.player_overlays.item().as(PlayerOverlay); + this.basic_info.like_count = this.primary_info?.menu?.top_level_buttons?.get({ icon_type: 'LIKE' })?.as(ToggleButton)?.like_count; this.basic_info.is_liked = this.primary_info?.menu?.top_level_buttons?.get({ icon_type: 'LIKE' })?.as(ToggleButton)?.is_toggled; this.basic_info.is_disliked = this.primary_info?.menu?.top_level_buttons?.get({ icon_type: 'DISLIKE' })?.as(ToggleButton)?.is_toggled; + const comments_entry_point = results.get({ target_id: 'comments-entry-point' })?.as(ItemSection); + this.comments_entry_point_header = comments_entry_point?.contents?.get({ type: 'CommentsEntryPointHeader' })?.as(CommentsEntryPointHeader); this.livechat = next?.contents_memo.getType(LiveChat)?.[0]; } } + /** * Applies given filter to the watch next feed. */ async selectFilter(name: string) { if (!this.filters.includes(name)) throw new InnertubeError('Invalid filter', { available_filters: this.filters }); + const filter = this.related_chip_cloud?.chips?.get({ text: name }); - if (filter?.is_selected) - return this; + if (filter?.is_selected) return this; + const response = await filter?.endpoint?.call(this.#actions, undefined, true); const data = response?.on_response_received_endpoints?.get({ target_id: 'watch-next-feed' }); + this.watch_next_feed = data?.contents; + return this; } + /** * Retrieves watch next feed continuation. */ async getWatchNextContinuation() { const response = await this.#watch_next_continuation?.endpoint.call(this.#actions, undefined, true); const data = response?.on_response_received_endpoints?.get({ type: 'appendContinuationItemsAction' }); + + if (!data) + throw new InnertubeError('Continuation not found'); + this.watch_next_feed = data?.contents; this.#watch_next_continuation = this.watch_next_feed?.pop()?.as(ContinuationItem); - return this.watch_next_feed?.filterType(CompactVideo, CompactMix); + + return this; } + /** * Likes the video. - * */ async like() { const button = this.primary_info?.menu?.top_level_buttons?.get({ button_id: 'TOGGLE_BUTTON_ID_TYPE_LIKE' })?.as(ToggleButton); + if (!button) throw new InnertubeError('Like button not found', { video_id: this.basic_info.id }); + if (button.is_toggled) throw new InnertubeError('This video is already liked', { video_id: this.basic_info.id }); + const response = await button.endpoint.call(this.#actions); + return response; } + /** * Dislikes the video. - * */ async dislike() { const button = this.primary_info?.menu?.top_level_buttons?.get({ button_id: 'TOGGLE_BUTTON_ID_TYPE_DISLIKE' })?.as(ToggleButton); + if (!button) throw new InnertubeError('Dislike button not found', { video_id: this.basic_info.id }); + if (button.is_toggled) throw new InnertubeError('This video is already disliked', { video_id: this.basic_info.id }); + const response = await button.endpoint.call(this.#actions); + return response; } + /** * Removes like/dislike. - * */ async removeLike() { const button = this.primary_info?.menu?.top_level_buttons?.get({ is_toggled: true })?.as(ToggleButton); + if (!button) throw new InnertubeError('This video is not liked/disliked', { video_id: this.basic_info.id }); + const response = await button.toggled_endpoint.call(this.#actions); + return response; } + /** * Retrieves Live Chat if available. */ @@ -200,15 +242,19 @@ class VideoInfo { throw new InnertubeError('Live Chat is not available', { video_id: this.basic_info.id }); return new LiveChatWrap(this); } + get filters() { return this.related_chip_cloud?.chips?.map((chip) => chip.text.toString()) || []; } + get actions() { return this.#actions; } + get page() { return this.#page; } + /** * Get songs used in the video. */ @@ -247,19 +293,25 @@ class VideoInfo { */ return []; } + chooseFormat(options: FormatOptions) { if (!this.streaming_data) throw new InnertubeError('Streaming data not available', { video_id: this.basic_info.id }); + const formats = [ ...(this.streaming_data.formats || []), ...(this.streaming_data.adaptive_formats || []) ]; + const requires_audio = options.type ? options.type.includes('audio') : true; const requires_video = options.type ? options.type.includes('video') : true; const quality = options.quality || '360p'; + let best_width = -1; + const is_best = [ 'best', 'bestefficiency' ].includes(quality); const use_most_efficient = quality !== 'best'; + let candidates = formats.filter((format) => { if (requires_audio && !format.has_audio) return false; @@ -273,19 +325,23 @@ class VideoInfo { best_width = format.width; return true; }); - if (candidates.length === 0) { + + if (!candidates.length) { throw new InnertubeError('No matching formats found', { options }); } + if (is_best && requires_video) candidates = candidates.filter((format) => format.width === best_width); + if (requires_audio && !requires_video) { const audio_only = candidates.filter((format) => !format.has_video); if (audio_only.length > 0) { candidates = audio_only; } } + if (use_most_efficient) { // Sort by bitrate (lower is better) candidates.sort((a, b) => a.bitrate - b.bitrate); @@ -293,6 +349,7 @@ class VideoInfo { // Sort by bitrate (higher is better) candidates.sort((a, b) => b.bitrate - a.bitrate); } + return candidates[0]; } @@ -363,6 +420,7 @@ class VideoInfo { #generateRepresentationVideo(set: XMLBuilder, format: Format, url_transformer: URLTransformer) { const codecs = getStringBetweenStrings(format.mime_type, 'codecs="', '"'); + if (!format.index_range || !format.init_range) throw new InnertubeError('Index and init ranges not available', { format }); @@ -421,7 +479,6 @@ class VideoInfo { } /** - * * @param options - download options. */ async download(options: DownloadOptions = {}) { @@ -431,6 +488,7 @@ class VideoInfo { throw new InnertubeError('Video is login required', { video: this, error_type: 'LOGIN_REQUIRED' }); if (!this.streaming_data) throw new InnertubeError('Streaming data not available.', { video: this, error_type: 'NO_STREAMING_DATA' }); + const opts: DownloadOptions = { quality: '360p', type: 'video+audio', @@ -461,15 +519,18 @@ class VideoInfo { return body; } + // We need to download in chunks. const chunk_size = 1048576 * 10; // 10MB + let chunk_start = (options.range ? options.range.start : 0); let chunk_end = (options.range ? options.range.end : chunk_size); let must_end = false; let cancel: AbortController; - const readableStream = new ReadableStream({ + + const readable_stream = new ReadableStream({ // eslint-disable-next-line @typescript-eslint/no-empty-function start() {}, pull: async (controller) => { @@ -477,12 +538,15 @@ class VideoInfo { controller.close(); return; } + if ((chunk_end >= format.content_length) || options.range) { must_end = true; } + return new Promise(async (resolve, reject) => { try { cancel = new AbortController(); + const response = await this.#actions.session.http.fetch_function(`${format_url}&cpn=${this.#cpn}&range=${chunk_start}-${chunk_end || ''}`, { method: 'GET', headers: { @@ -492,10 +556,11 @@ class VideoInfo { }, signal: cancel.signal }); + const body = response.body; - if (!body) { + + if (!body) throw new InnertubeError('Could not get ReadableStream from fetch Response.', { video: this, error_type: 'FETCH_FAILED', response }); - } for await (const chunk of streamToIterable(body)) { controller.enqueue(chunk); @@ -503,6 +568,7 @@ class VideoInfo { chunk_start = chunk_end + 1; chunk_end += chunk_size; + resolve(); return; } catch (e: any) { @@ -520,8 +586,8 @@ class VideoInfo { } }); - return readableStream; - + return readable_stream; } } -export default VideoInfo; + +export default VideoInfo; \ No newline at end of file diff --git a/src/parser/ytmusic/Album.js b/src/parser/ytmusic/Album.js index 365373ce..4552dc72 100644 --- a/src/parser/ytmusic/Album.js +++ b/src/parser/ytmusic/Album.js @@ -1,9 +1,9 @@ import Parser from '../index'; -/** @namespace */ class Album { #page; #actions; + /** * @param {object} response - API response. * @param {import('../../core/Actions').default} actions @@ -11,16 +11,22 @@ class Album { constructor(response, actions) { this.#page = Parser.parseResponse(response.data); this.#actions = actions; + /** @type {import('../classes/MusicDetailHeader')[]} */ this.header = this.#page.header; + /** @type {string} */ this.url = this.#page.microformat.url_canonical; + /** @type {import('../classes/MusicResponsiveListItem')[]} */ this.contents = this.#page.contents_memo.get('MusicShelf')?.[0].contents; + this.sections = this.#page.contents_memo.get('MusicCarouselShelf') || []; } + get page() { return this.#page; } } -export default Album; + +export default Album; \ No newline at end of file diff --git a/src/parser/ytmusic/Artist.js b/src/parser/ytmusic/Artist.js index bd50722f..785418d6 100644 --- a/src/parser/ytmusic/Artist.js +++ b/src/parser/ytmusic/Artist.js @@ -1,9 +1,10 @@ import Parser from '../index'; import { observe } from '../helpers'; -/** @namespace */ + class Artist { #page; #actions; + /** * @param {object} response - API response. * @param {import('../../core/Actions').default} actions @@ -11,17 +12,22 @@ class Artist { constructor(response, actions) { this.#page = Parser.parseResponse(response.data); this.#actions = actions; + this.header = this.page.header; + const music_shelf = this.#page.contents_memo.get('MusicShelf'); const music_carousel_shelf = this.#page.contents_memo.get('MusicCarouselShelf'); + /** @type {import('../classes/MusicShelf')[] | import('../classes/MusicCarouselShelf')[]} */ this.sections = observe([ ...music_shelf, ...music_carousel_shelf ]); } + async getAllSongs() { const shelf = this.sections.get({ type: 'MusicShelf' }); const page = await shelf.endpoint.call(this.#actions, 'YTMUSIC'); return page.contents_memo.get('MusicPlaylistShelf')?.[0] || []; } + get page() { return this.#page; } diff --git a/src/parser/ytmusic/Explore.js b/src/parser/ytmusic/Explore.js index 16914cfb..286415f4 100644 --- a/src/parser/ytmusic/Explore.js +++ b/src/parser/ytmusic/Explore.js @@ -1,6 +1,5 @@ import Parser from '../index'; -/** @namespace */ class Explore { #page; /** @@ -8,12 +7,16 @@ class Explore { */ constructor(response) { this.#page = Parser.parseResponse(response.data); + const tab = this.page.contents.tabs.get({ selected: true }); + this.top_buttons = tab.content.contents.get({ type: 'Grid' }).items; this.sections = tab.content.contents.getAll({ type: 'MusicCarouselShelf' }); } + get page() { return this.#page; } } -export default Explore; + +export default Explore; \ No newline at end of file diff --git a/src/parser/ytmusic/HomeFeed.js b/src/parser/ytmusic/HomeFeed.js index a163fd35..96f178eb 100644 --- a/src/parser/ytmusic/HomeFeed.js +++ b/src/parser/ytmusic/HomeFeed.js @@ -1,10 +1,10 @@ import Parser from '../index'; -/** @namespace */ class HomeFeed { #page; #actions; #continuation; + /** * @param {object} response - API response. * @param {import('../../core/Actions').default} actions @@ -12,22 +12,26 @@ class HomeFeed { constructor(response, actions) { this.#actions = actions; this.#page = Parser.parseResponse(response.data); + const tab = this.#page.contents.tabs.get({ title: 'Home' }); this.#continuation = tab.content?.continuation || this.#page.continuation_contents.continuation; + /** @type {import('../classes/MusicCarouselShelf')[]} */ this.sections = tab.content?.contents || this.#page.continuation_contents.contents; } + /** * Retrieves home feed continuation. - * * @returns {Promise.} */ async getContinuation() { const response = await this.#actions.browse(this.#continuation, { is_ctoken: true, client: 'YTMUSIC' }); return new HomeFeed(response, this.#actions); } + get page() { return this.#page; } } -export default HomeFeed; + +export default HomeFeed; \ No newline at end of file diff --git a/src/parser/ytmusic/Library.js b/src/parser/ytmusic/Library.js index d1929fde..c7022a7e 100644 --- a/src/parser/ytmusic/Library.js +++ b/src/parser/ytmusic/Library.js @@ -1,9 +1,10 @@ import Parser from '../index'; // Const { observe, InnertubeError } = require('../../utils/Utils'); -/** @namespace */ + class Library { #page; + /** * @param {object} response - API response. */ @@ -11,8 +12,10 @@ class Library { this.#page = Parser.parseResponse(response.data); // TODO: finish this } + get page() { return this.#page; } } -export default Library; + +export default Library; \ No newline at end of file diff --git a/src/parser/ytmusic/Search.ts b/src/parser/ytmusic/Search.ts index e34aeaa9..614ba4bf 100644 --- a/src/parser/ytmusic/Search.ts +++ b/src/parser/ytmusic/Search.ts @@ -1,6 +1,3 @@ -import Parser, { ParsedResponse } from '../index'; -import { InnertubeError } from '../../utils/Utils'; -import Actions, { AxioslikeResponse } from '../../core/Actions'; import DidYouMean from '../classes/DidYouMean'; import ShowingResultsFor from '../classes/ShowingResultsFor'; import MusicShelf from '../classes/MusicShelf'; @@ -9,50 +6,66 @@ import RichShelf from '../classes/RichShelf'; import ReelShelf from '../classes/ReelShelf'; import ChipCloudChip from '../classes/ChipCloudChip'; +import Parser, { ParsedResponse } from '../index'; +import { InnertubeError } from '../../utils/Utils'; +import Actions, { AxioslikeResponse } from '../../core/Actions'; + class Search { #page; #actions; #continuation; #header; + did_you_mean; showing_results_for; results; sections; - constructor(response: AxioslikeResponse | ParsedResponse, actions: Actions, args: { - is_continuation?: boolean, - is_filtered?: boolean - } = {}) { + + constructor(response: AxioslikeResponse | ParsedResponse, actions: Actions, args: { is_continuation?: boolean, is_filtered?: boolean } = {}) { this.#actions = actions; + this.#page = args.is_continuation ? - response as ParsedResponse : + response as ParsedResponse : Parser.parseResponse((response as AxioslikeResponse).data); + const tab = this.#page.contents.item().key('tabs').parsed().array().get({ selected: true }); + const shelves = tab?.key('content').parsed().item().key('contents').parsed().array(); const item_section = shelves?.get({ type: 'ItemSection' }); + this.#header = tab?.key('content').parsed().item().key('header').parsed().item(); + this.did_you_mean = item_section?.key('contents').parsed().array().firstOfType(DidYouMean); this.showing_results_for = item_section?.key('contents').parsed().array().firstOfType(ShowingResultsFor); + (!!this.did_you_mean || !!this.showing_results_for) && shelves?.shift(); + if (args.is_continuation || args.is_filtered) { const shelf = shelves?.firstOfType(MusicShelf); this.results = shelf?.contents; this.#continuation = shelf?.continuation; return; } + this.sections = shelves?.as(MusicShelf, Shelf, RichShelf, ReelShelf)?.map((shelf) => ({ title: shelf.title.toString(), contents: shelf.key('contents').parsed().array(), getMore: () => this.#getMore(shelf) })) || []; } + async #getMore(shelf: MusicShelf | Shelf | RichShelf | ReelShelf) { if (!shelf.endpoint) throw new InnertubeError(`${shelf.title} doesn't have more items`); + const response = await shelf.endpoint.call(this.#actions, 'YTMUSIC', true); + if (!response) throw new InnertubeError('Endpoint did not return any data'); + return new Search(response, this.#actions, { is_continuation: true }); } + /** * Retrieves continuation, only works for individual sections or filtered results. * @@ -60,12 +73,16 @@ class Search { async getContinuation() { if (!this.#continuation) throw new InnertubeError('Looks like you\'ve reached the end'); + const response = await this.#actions.search({ ctoken: this.#continuation, client: 'YTMUSIC' }); const data = response.data.continuationContents.musicShelfContinuation; + this.results = Parser.parse(data.contents); this.#continuation = data?.continuations?.[0]?.nextContinuationData?.continuation; + return this; } + /** * Applies given filter to the search. * @@ -73,38 +90,50 @@ class Search { async selectFilter(name: string) { if (!this.filters?.includes(name)) throw new InnertubeError('Invalid filter', { available_filters: this.filters }); + // TODO: make sure this is a ChipCloudChip or use the property accessor helpers on YTNode const filter = this.#header?.key('chips').parsed().array().as(ChipCloudChip).get({ text: name }); - if (filter?.is_selected) - return this; + if (filter?.is_selected) return this; + const response = await filter?.endpoint?.call(this.#actions, 'YTMUSIC', true); + if (!response) throw new InnertubeError('Endpoint did not return any data'); + return new Search(response, this.#actions, { is_continuation: true }); } + get has_continuation() { return !!this.#continuation; } + get filters() { return this.#header?.key('chips').parsed().array().as(ChipCloudChip).map((chip) => chip.text); } + get songs() { return this.sections?.find((section) => section.title === 'Songs'); } + get videos() { return this.sections?.find((section) => section.title === 'Videos'); } + get albums() { return this.sections?.find((section) => section.title === 'Albums'); } + get artists() { return this.sections?.find((section) => section.title === 'Artists'); } + get playlists() { return this.sections?.find((section) => section.title === 'Playlists'); } + get page() { return this.#page; } } -export default Search; + +export default Search; \ No newline at end of file diff --git a/src/proto/index.js b/src/proto/index.js index 045c3724..df5d234d 100644 --- a/src/proto/index.js +++ b/src/proto/index.js @@ -12,6 +12,7 @@ class Proto { const buf = messages.VisitorData.encode({ id, timestamp }); return encodeURIComponent(Buffer.from(buf).toString('base64').replace(/\/|\+/g, '_')); } + /** * Encodes basic channel analytics parameters. * @@ -26,6 +27,7 @@ class Proto { }); return encodeURIComponent(Buffer.from(buf).toString('base64')); } + /** * Encodes search filters. * @@ -45,6 +47,7 @@ class Proto { month: 4, year: 5 }; + const type = { all: null, video: 1, @@ -52,45 +55,55 @@ class Proto { playlist: 3, movie: 4 }; + const duration = { all: null, short: 1, long: 2, medium: 3 }; + const order = { relevance: null, rating: 1, upload_date: 2, view_count: 3 }; + const data = {}; + if (filters) data.filters = {}; else data.no_filter = 0; + if (filters) { if (filters.upload_date && filters.type !== 'video') throw new Error(`Upload date filter cannot be used with type ${filters.type}`); + if (filters.upload_date) { data.filters.upload_date = upload_date[filters.upload_date]; } + if (filters.type) { data.filters.type = type[filters.type]; } + if (filters.duration) { data.filters.duration = duration[filters.duration]; } + if (filters.sort_by && filters.sort_by !== 'relevance') { data.sort_by = order[filters.sort_by]; } } + const buf = messages.SearchFilter.encode(data); return encodeURIComponent(Buffer.from(buf).toString('base64')); } + /** * Encodes YouTube Music search filters. - * * @param {object} filters * @param {string} filters.type - all | song | video | album | playlist | artist * @returns {string} @@ -101,13 +114,15 @@ class Proto { type: {} } }; + data.filters.type[filters.type || 'all'] = 1; + const buf = messages.MusicSearchFilter.encode(data); return encodeURIComponent(Buffer.from(buf).toString('base64')); } + /** * Encodes livechat message parameters. - * * @param {string} channel_id * @param {string} video_id * @returns {string} @@ -121,11 +136,12 @@ class Proto { }, number_0: 1, number_1: 4 }); + return Buffer.from(encodeURIComponent(Buffer.from(buf).toString('base64'))).toString('base64'); } + /** * Encodes comment section parameters. - * * @param {string} video_id * @param {object} options * @param {string} [options.type] @@ -137,6 +153,7 @@ class Proto { TOP_COMMENTS: 0, NEWEST_FIRST: 1 }; + const buf = messages.GetCommentsSectionParams.encode({ ctx: { video_id @@ -151,11 +168,12 @@ class Proto { target: 'comments-section' } }); + return encodeURIComponent(Buffer.from(buf).toString('base64')); } + /** * Encodes comment replies parameters. - * * @param {string} video_id * @param {string} comment_id * @returns {string} @@ -178,11 +196,12 @@ class Proto { target: `comment-replies-item-${comment_id}` } }); + return encodeURIComponent(Buffer.from(buf).toString('base64')); } + /** * Encodes comment parameters. - * * @param {string} video_id * @returns {string} */ @@ -195,9 +214,9 @@ class Proto { }); return encodeURIComponent(Buffer.from(buf).toString('base64')); } + /** * Encodes comment reply parameters. - * * @param {string} comment_id * @param {string} video_id * @returns {string} @@ -212,9 +231,9 @@ class Proto { }); return encodeURIComponent(Buffer.from(buf).toString('base64')); } + /** * Encodes comment action parameters. - * * @param {*} type * @param {object} [args] * @param {string} [args.comment_id] @@ -225,10 +244,12 @@ class Proto { */ static encodeCommentActionParams(type, args = {}) { const data = {}; + data.type = type; data.video_id = args.video_id || ''; data.comment_id = args.comment_id || ''; data.unk_num = 2; + if (args.hasOwnProperty('text')) { args.comment_id && (delete data.unk_num); data.translate_comment_params = { @@ -241,12 +262,13 @@ class Proto { target_language: args.target_language }; } + const buf = messages.PeformCommentActionParams.encode(data); return encodeURIComponent(Buffer.from(buf).toString('base64')); } + /** * Encodes notification preference parameters. - * * @param {string} channel_id * @param {number} index * @returns {string} @@ -258,11 +280,12 @@ class Proto { }, number_0: 0, number_1: 4 }); + return encodeURIComponent(Buffer.from(buf).toString('base64')); } + /** * Encodes sound info parameters. - * * @param {string} id * @returns {string} */ @@ -278,8 +301,10 @@ class Proto { } } }; + const buf = messages.SoundInfoParams.encode(data); return encodeURIComponent(Buffer.from(buf).toString('base64')); } } -export default Proto; + +export default Proto; \ No newline at end of file diff --git a/src/utils/Cache.ts b/src/utils/Cache.ts index 595aaec4..cbc81033 100644 --- a/src/utils/Cache.ts +++ b/src/utils/Cache.ts @@ -6,6 +6,7 @@ import { getRuntime } from './Utils'; export default class UniversalCache { #persistent_directory: string; #persistent: boolean; + constructor(persistent = false, persistent_directory?: string) { this.#persistent_directory = persistent_directory || UniversalCache.default_persistent_directory; this.#persistent = persistent; @@ -16,10 +17,8 @@ export default class UniversalCache { case 'deno': const Deno: any = Reflect.get(globalThis, 'Deno'); return `${Deno.env.get('TMPDIR') || Deno.env.get('TMP') || Deno.env.get('TEMP') || '/tmp'}/youtubei.js`; - case 'node': return `${Reflect.get(module, 'require')('os').tmpdir()}/youtubei.js`; - default: return ''; } @@ -30,10 +29,8 @@ export default class UniversalCache { case 'deno': const Deno: any = Reflect.get(globalThis, 'Deno'); return `${Deno.cwd()}/.cache/youtubei.js`; - case 'node': return Reflect.get(module, 'require')('path').resolve(__dirname, '..', '..', '.cache', 'youtubei.js'); - default: return ''; } diff --git a/src/utils/EventEmitterLike.ts b/src/utils/EventEmitterLike.ts index c4fa35d1..56926c02 100644 --- a/src/utils/EventEmitterLike.ts +++ b/src/utils/EventEmitterLike.ts @@ -20,13 +20,16 @@ if (!Reflect.has(globalThis, 'CustomEvent')) { export default class EventEmitterLike extends EventTarget { #legacy_listeners = new Map<(...args: any[]) => void, EventListener>(); + constructor() { super(); } + emit(type: string, ...args: any[]) { const event = new CustomEvent(type, { detail: args }); this.dispatchEvent(event); } + on(type: string, listener: (...args: any[]) => void) { const wrapper: EventListener = (ev) => { if (ev instanceof CustomEvent) { @@ -38,6 +41,7 @@ export default class EventEmitterLike extends EventTarget { this.#legacy_listeners.set(listener, wrapper); this.addEventListener(type, wrapper); } + once(type: string, listener: (...args: any[]) => void) { const wrapper: EventListener = (ev) => { if (ev instanceof CustomEvent) { @@ -50,6 +54,7 @@ export default class EventEmitterLike extends EventTarget { this.#legacy_listeners.set(listener, wrapper); this.addEventListener(type, wrapper); } + off(type: string, listener: (...args: any[]) => void) { const wrapper = this.#legacy_listeners.get(listener); if (wrapper) { @@ -57,4 +62,4 @@ export default class EventEmitterLike extends EventTarget { this.#legacy_listeners.delete(listener); } } -} +} \ No newline at end of file diff --git a/src/utils/HTTPClient.ts b/src/utils/HTTPClient.ts index 4f2dd497..e1c77861 100644 --- a/src/utils/HTTPClient.ts +++ b/src/utils/HTTPClient.ts @@ -5,13 +5,14 @@ import { generateSidAuth, getRandomUserAgent, getStringBetweenStrings, Innertube export type FetchFunction = typeof fetch; export interface HTTPClientInit { - baseURL?: string; + baseURL?: string; } export default class HTTPClient { #session: Session; #cookie?: string; #fetch: FetchFunction; + constructor(session: Session, cookie?: string, fetch?: FetchFunction) { this.#session = session; this.#cookie = cookie; @@ -30,21 +31,19 @@ export default class HTTPClient { const baseURL = init?.baseURL || innertube_url; const request_url = - typeof input === 'string' ? - (!baseURL.endsWith('/') && !input.startsWith('/')) ? - new URL(`${baseURL}/${input}`) : - new URL(baseURL + input) : - input instanceof URL ? - input : - new URL(input.url, baseURL); + typeof input === 'string' ? + (!baseURL.endsWith('/') && !input.startsWith('/')) ? + new URL(`${baseURL}/${input}`) : + new URL(baseURL + input) : + input instanceof URL ? + input : new URL(input.url, baseURL); const headers = - init?.headers || - (input instanceof Request ? input.headers : new Headers()) || - new Headers(); + init?.headers || + (input instanceof Request ? input.headers : new Headers()) || + new Headers(); - const body = - init?.body || (input instanceof Request ? input.body : undefined); + const body = init?.body || (input instanceof Request ? input.body : undefined); const request_headers = new Headers(headers); @@ -62,36 +61,44 @@ export default class HTTPClient { request_url.searchParams.set('key', this.#session.key); request_url.searchParams.set('prettyPrint', 'false'); - const contentType = request_headers.get('Content-Type'); + const content_type = request_headers.get('Content-Type'); let request_body = body; const is_innertube_req = baseURL === innertube_url; // Copy context into payload when possible - if (contentType === 'application/json' && is_innertube_req && (typeof body === 'string')) { + if (content_type === 'application/json' && is_innertube_req && (typeof body === 'string')) { const json = JSON.parse(body); + const n_body = { ...json, // Deep copy since we're gonna be modifying it context: JSON.parse(JSON.stringify(this.#session.context)) }; + this.#adjustContext(n_body.context, n_body.client); request_headers.set('x-youtube-client-version', n_body.context.client.clientVersion); + delete n_body.client; + request_body = JSON.stringify(n_body); } // Authenticate if (this.#session.logged_in && is_innertube_req) { const oauth = this.#session.oauth; + if (oauth.validateCredentials()) { // Check if the access token is valid to avoid authorization errors. await oauth.checkAccessTokenValidity(); + request_headers.set('authorization', `Bearer ${oauth.credentials.access_token}`); + // Remove API key as it is not required when using oauth. request_url.searchParams.delete('key'); } + if (this.#cookie) { const papisid = getStringBetweenStrings(this.#cookie, 'PAPISID=', ';'); if (papisid) { diff --git a/src/utils/Utils.ts b/src/utils/Utils.ts index 77fd5bd8..879961ca 100644 --- a/src/utils/Utils.ts +++ b/src/utils/Utils.ts @@ -12,32 +12,27 @@ export class InnertubeError extends Error { constructor(message: string, info?: any) { super(message); + if (info) { this.info = info; } + this.date = new Date(); this.version = package_json.version; } } + export class ParsingError extends InnertubeError { } - export class DownloadError extends InnertubeError { } - export class MissingParamError extends InnertubeError { } - export class UnavailableContentError extends InnertubeError { } - export class NoStreamingDataError extends InnertubeError { } - export class OAuthError extends InnertubeError { } - export class PlayerError extends Error { } - export class SessionError extends Error { } /** * Utility to help access deep properties of an object. - * * @param obj - the object. * @param key - key of the property being accessed. * @param target - anything that might be inside of the property. @@ -57,7 +52,6 @@ export function findNode(obj: any, key: string, target: string, depth: number, s /** * Compares given objects. May not work correctly for * objects with methods. - * */ export function deepCompare(obj1: any, obj2: any) { const keys = Reflect.ownKeys(obj1); @@ -72,7 +66,6 @@ export function deepCompare(obj1: any, obj2: any) { /** * Finds a string between two delimiters. - * * @param data - the data. * @param start_string - start string. * @param end_string - end string. @@ -83,8 +76,6 @@ export function getStringBetweenStrings(data: string, start_string: string, end_ return match ? match[1] : undefined; } -/** - */ export function escapeStringRegexp(input: string): string { return input.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d'); } @@ -93,7 +84,6 @@ export type DeviceCategory = 'mobile' | 'desktop'; /** * Returns a random user agent. - * * @param type - mobile | desktop */ export function getRandomUserAgent(type: DeviceCategory): UserAgent['data'] { @@ -109,9 +99,6 @@ export function getRandomUserAgent(type: DeviceCategory): UserAgent['data'] { } } -/** - * - */ export async function sha1Hash(str: string) { const SubtleCrypto = getRuntime() === 'node' ? (Reflect.get(module, 'require')('crypto').webcrypto as unknown as Crypto).subtle : window.crypto.subtle; const byteToHex = [ @@ -132,9 +119,7 @@ export async function sha1Hash(str: string) { 'e0', 'e1', 'e2', 'e3', 'e4', 'e5', 'e6', 'e7', 'e8', 'e9', 'ea', 'eb', 'ec', 'ed', 'ee', 'ef', 'f0', 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'fa', 'fb', 'fc', 'fd', 'fe', 'ff' ]; - /** - * - */ + function hex(arrayBuffer: ArrayBuffer) { const buff = new Uint8Array(arrayBuffer); const hexOctets = []; @@ -142,18 +127,21 @@ export async function sha1Hash(str: string) { hexOctets.push(byteToHex[buff[i]]); return hexOctets.join(''); } + return hex(await SubtleCrypto.digest('SHA-1', new TextEncoder().encode(str))); } + /** * Generates an authentication token from a cookies' sid. - * * @param sid - Sid extracted from cookies */ export async function generateSidAuth(sid: string): Promise { const youtube = 'https://www.youtube.com'; + const timestamp = Math.floor(new Date().getTime() / 1000); const input = [ timestamp, sid, youtube ].join(' '); const gen_hash = await sha1Hash(input); + return [ 'SAPISIDHASH', [ timestamp, gen_hash ].join('_') ].join(' '); } @@ -163,16 +151,18 @@ export async function generateSidAuth(sid: string): Promise { */ export function generateRandomString(length: number): string { const result = []; + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + for (let i = 0; i < length; i++) { result.push(alphabet.charAt(Math.floor(Math.random() * alphabet.length))); } + return result.join(''); } /** * Converts time (h:m:s) to seconds. - * * @returns seconds */ export function timeToSeconds(time: string) { @@ -191,7 +181,6 @@ export function timeToSeconds(time: string) { /** * Converts strings in camelCase to snake_case. - * * @param string - The string in camelCase. */ export function camelToSnake(string: string) { @@ -200,8 +189,6 @@ export function camelToSnake(string: string) { /** * Checks if a given client is valid. - * - * @returns */ export function isValidClient(client: string) { return VALID_CLIENTS.has(client); @@ -238,9 +225,6 @@ export function refineNTokenData(data: string) { .replace(/""/g, '').replace(/length]\)}"/g, 'length])}'); } -/** - * - */ export function uuidv4() { if (getRuntime() === 'node') { return Reflect.get(module, 'require')('crypto').webcrypto.randomUUID(); @@ -255,15 +239,10 @@ export function uuidv4() { const c = parseInt(cc); return (c ^ window.crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16); }); - - } export type Runtime = 'node' | 'deno' | 'browser'; -/** - * - */ export function getRuntime(): Runtime { if ((typeof process !== 'undefined') && (process?.versions?.node)) return 'node'; @@ -272,18 +251,13 @@ export function getRuntime(): Runtime { return 'browser'; } -/** - * - */ export function isServer() { return [ 'node', 'deno' ].includes(getRuntime()); } -/** - * - */ export async function* streamToIterable(stream: ReadableStream) { const reader = stream.getReader(); + try { while (true) { const { done, value } = await reader.read(); @@ -299,19 +273,18 @@ export async function* streamToIterable(stream: ReadableStream) { export const debugFetch: FetchFunction = (input, init) => { const url = - typeof input === 'string' ? - new URL(input) : - input instanceof URL ? - input : - new URL(input.url); + typeof input === 'string' ? + new URL(input) : + input instanceof URL ? + input : new URL(input.url); const headers = - init?.headers ? - new Headers(init.headers) : - input instanceof Request ? - input.headers : - new Headers(); + init?.headers ? + new Headers(init.headers) : + input instanceof Request ? + input.headers : + new Headers(); const arr_headers = [ ...headers ]; @@ -338,4 +311,4 @@ export const debugFetch: FetchFunction = (input, init) => { ); return globalThis.fetch(input, init); -}; +}; \ No newline at end of file diff --git a/src/utils/index.ts b/src/utils/index.ts index 145acab3..2c98d049 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -3,4 +3,4 @@ export * as Constants from './Constants'; export { default as UniversalCache } from './Cache'; export { default as EventEmitter } from './EventEmitterLike'; export { default as HTTPClient } from './HTTPClient'; -export * from './HTTPClient'; +export * from './HTTPClient'; \ No newline at end of file