diff --git a/src/Innertube.ts b/src/Innertube.ts index 84c2fd300..0256415e8 100644 --- a/src/Innertube.ts +++ b/src/Innertube.ts @@ -1,31 +1,33 @@ import Session, { SessionOptions } from './core/Session'; +import type { ParsedResponse } from './parser'; +import type { ActionsResponse } from './core/Actions'; -import Search from './parser/youtube/Search'; +import NavigationEndpoint from './parser/classes/NavigationEndpoint'; 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 History from './parser/youtube/History'; +import Library from './parser/youtube/Library'; import NotificationsMenu from './parser/youtube/NotificationsMenu'; +import Playlist from './parser/youtube/Playlist'; +import Search from './parser/youtube/Search'; import VideoInfo, { DownloadOptions, FormatOptions } from './parser/youtube/VideoInfo'; -import NavigationEndpoint from './parser/classes/NavigationEndpoint'; - -import { ParsedResponse } from './parser'; -import { ActionsResponse } from './core/Actions'; +import AccountManager from './core/AccountManager'; import Feed from './core/Feed'; +import InteractionManager from './core/InteractionManager'; import YTMusic from './core/Music'; -import Studio from './core/Studio'; -import HomeFeed from './parser/youtube/HomeFeed'; -import AccountManager from './core/AccountManager'; import PlaylistManager from './core/PlaylistManager'; -import InteractionManager from './core/InteractionManager'; +import Studio from './core/Studio'; import TabbedFeed from './core/TabbedFeed'; -import Constants from './utils/Constants'; +import HomeFeed from './parser/youtube/HomeFeed'; import Proto from './proto/index'; +import Constants from './utils/Constants'; + +import type Actions from './core/Actions'; +import type Format from './parser/classes/misc/Format'; -import { throwIfMissing, generateRandomString } from './utils/Utils'; +import { generateRandomString, throwIfMissing } from './utils/Utils'; export type InnertubeConfig = SessionOptions; @@ -36,16 +38,16 @@ export interface SearchFilters { sort_by?: 'relevance' | 'rating' | 'upload_date' | 'view_count' } -export type InnerTubeClient = 'WEB' | 'ANDROID' | 'YTMUSIC_ANDROID' | 'YTMUSIC' | 'TV_EMBEDDED'; +export type InnerTubeClient = 'WEB' | 'ANDROID' | 'YTMUSIC_ANDROID' | 'YTMUSIC' | 'YTSTUDIO_ANDROID' | 'TV_EMBEDDED'; class Innertube { - session; - account; - playlist; - interact; - music; - studio; - actions; + session: Session; + account: AccountManager; + playlist: PlaylistManager; + interact: InteractionManager; + music: YTMusic; + studio: Studio; + actions: Actions; constructor(session: Session) { this.session = session; @@ -57,7 +59,7 @@ class Innertube { this.actions = this.session.actions; } - static async create(config: InnertubeConfig = {}) { + static async create(config: InnertubeConfig = {}): Promise { return new Innertube(await Session.create(config)); } @@ -66,7 +68,9 @@ class Innertube { * @param video_id - The video id. * @param client - The client to use. */ - async getInfo(video_id: string, client?: InnerTubeClient) { + async getInfo(video_id: string, client?: InnerTubeClient): Promise { + throwIfMissing({ video_id }); + const cpn = generateRandomString(16); const initial_info = this.actions.getVideoInfo(video_id, cpn, client); @@ -81,7 +85,9 @@ class Innertube { * @param video_id - The video id. * @param client - The client to use. */ - async getBasicInfo(video_id: string, client?: InnerTubeClient) { + async getBasicInfo(video_id: string, client?: InnerTubeClient): Promise { + throwIfMissing({ video_id }); + const cpn = generateRandomString(16); const response = await this.actions.getVideoInfo(video_id, cpn, client); @@ -93,7 +99,7 @@ class Innertube { * @param query - The search query. * @param filters - Search filters. */ - async search(query: string, filters: SearchFilters = {}) { + async search(query: string, filters: SearchFilters = {}): Promise { throwIfMissing({ query }); const args = { @@ -138,7 +144,7 @@ class Innertube { * @param video_id - The video id. * @param sort_by - Sorting options. */ - async getComments(video_id: string, sort_by?: 'TOP_COMMENTS' | 'NEWEST_FIRST') { + async getComments(video_id: string, sort_by?: 'TOP_COMMENTS' | 'NEWEST_FIRST'): Promise { throwIfMissing({ video_id }); const payload = Proto.encodeCommentsSectionParams(video_id, { @@ -153,7 +159,7 @@ class Innertube { /** * Retrieves YouTube's home feed (aka recommendations). */ - async getHomeFeed() { + async getHomeFeed(): Promise { const response = await this.actions.execute('/browse', { browseId: 'FEwhat_to_watch' }); return new HomeFeed(this.actions, response.data); } @@ -161,7 +167,7 @@ class Innertube { /** * Returns the account's library. */ - async getLibrary() { + async getLibrary(): Promise { const response = await this.actions.execute('/browse', { browseId: 'FElibrary' }); return new Library(response.data, this.actions); } @@ -170,7 +176,7 @@ class Innertube { * Retrieves watch history. * Which can also be achieved with {@link getLibrary}. */ - async getHistory() { + async getHistory(): Promise { const response = await this.actions.execute('/browse', { browseId: 'FEhistory' }); return new History(this.actions, response.data); } @@ -178,7 +184,7 @@ class Innertube { /** * Retrieves trending content. */ - async getTrending() { + async getTrending(): Promise { const response = await this.actions.execute('/browse', { browseId: 'FEtrending' }); return new TabbedFeed(this.actions, response.data); } @@ -186,16 +192,16 @@ class Innertube { /** * Retrieves subscriptions feed. */ - async getSubscriptionsFeed() { + async getSubscriptionsFeed(): Promise { const response = await this.actions.execute('/browse', { browseId: 'FEsubscriptions' }); return new Feed(this.actions, response.data); } /** * Retrieves contents for a given channel. - * @param id - channel id + * @param id - Channel id */ - async getChannel(id: string) { + async getChannel(id: string): Promise { throwIfMissing({ id }); const response = await this.actions.execute('/browse', { browseId: id }); return new Channel(this.actions, response.data); @@ -204,7 +210,7 @@ class Innertube { /** * Retrieves notifications. */ - async getNotifications() { + async getNotifications(): Promise { const response = await this.actions.execute('/notification/get_notification_menu', { notificationsMenuRequestType: 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX' }); return new NotificationsMenu(this.actions, response); } @@ -220,8 +226,9 @@ class Innertube { /** * Retrieves playlist contents. + * @param id - Playlist id */ - async getPlaylist(id: string) { + async getPlaylist(id: string): Promise { throwIfMissing({ id }); if (!id.startsWith('VL')) { @@ -237,18 +244,21 @@ class Innertube { * Returns deciphered streaming data. * * If you wish to retrieve the video info too, have a look at {@link getBasicInfo} or {@link getInfo}. + * @param video_id - The video id. + * @param options - Format options. */ - async getStreamingData(video_id: string, options: FormatOptions = {}) { + async getStreamingData(video_id: string, options: FormatOptions = {}): Promise { const info = await this.getBasicInfo(video_id); return info.chooseFormat(options); } /** * Downloads a given video. If you only need the direct download link see {@link getStreamingData}. - * * If you wish to retrieve the video info too, have a look at {@link getBasicInfo} or {@link getInfo}. + * @param video_id - The video id. + * @param options - Download options. */ - async download(video_id: string, options?: DownloadOptions) { + async download(video_id: string, options?: DownloadOptions): Promise> { const info = await this.getBasicInfo(video_id, options?.client); return info.download(options); } @@ -258,8 +268,8 @@ class Innertube { * @param endpoint -The endpoint to call. * @param args - Call arguments. */ - call(endpoint: NavigationEndpoint, args: { [ key: string ]: any; parse: true }): Promise; - call(endpoint: NavigationEndpoint, args?: { [ key: string ]: any; parse?: false }): Promise; + call(endpoint: NavigationEndpoint, args: { [key: string]: any; parse: true }): Promise; + call(endpoint: NavigationEndpoint, args?: { [key: string]: any; parse?: false }): Promise; call(endpoint: NavigationEndpoint, args?: object): Promise { return endpoint.call(this.actions, args); } diff --git a/src/core/AccountManager.ts b/src/core/AccountManager.ts index d5991325f..4ded931dc 100644 --- a/src/core/AccountManager.ts +++ b/src/core/AccountManager.ts @@ -1,15 +1,22 @@ import Proto from '../proto/index'; -import Actions from './Actions'; +import type Actions from './Actions'; +import type { ActionsResponse } from './Actions'; import Analytics from '../parser/youtube/Analytics'; import TimeWatched from '../parser/youtube/TimeWatched'; import AccountInfo from '../parser/youtube/AccountInfo'; import Settings from '../parser/youtube/Settings'; + import { InnertubeError } from '../utils/Utils'; class AccountManager { - #actions; - channel; + #actions: Actions; + + channel: { + editName: (new_name: string) => Promise; + editDescription: (new_description: string) => Promise; + getBasicAnalytics: () => Promise; + }; constructor(actions: Actions) { this.#actions = actions; @@ -51,7 +58,7 @@ class AccountManager { /** * Retrieves channel info. */ - async getInfo() { + async getInfo(): Promise { if (!this.#actions.session.logged_in) throw new InnertubeError('You must be signed in to perform this operation.'); @@ -62,7 +69,7 @@ class AccountManager { /** * Retrieves time watched statistics. */ - async getTimeWatched() { + async getTimeWatched(): Promise { const response = await this.#actions.execute('/browse', { browseId: 'SPtime_watched', client: 'ANDROID' @@ -74,7 +81,7 @@ class AccountManager { /** * Opens YouTube settings. */ - async getSettings() { + async getSettings(): Promise { const response = await this.#actions.execute('/browse', { browseId: 'SPaccount_overview' }); @@ -85,7 +92,7 @@ class AccountManager { /** * Retrieves basic channel analytics. */ - async getAnalytics() { + async getAnalytics(): Promise { const info = await this.getInfo(); const params = Proto.encodeChannelAnalyticsParams(info.footers?.endpoint.payload.browseId); diff --git a/src/core/Actions.ts b/src/core/Actions.ts index b712e8638..8a65a83ca 100644 --- a/src/core/Actions.ts +++ b/src/core/Actions.ts @@ -1,6 +1,6 @@ -import Session from './Session'; import Parser, { ParsedResponse } from '../parser/index'; import { InnertubeError } from '../utils/Utils'; +import type Session from './Session'; export interface ApiResponse { success: boolean; @@ -11,13 +11,13 @@ export interface ApiResponse { export type ActionsResponse = Promise; class Actions { - #session; + #session: Session; constructor(session: Session) { this.#session = session; } - get session() { + get session(): Session { return this.#session; } @@ -25,7 +25,7 @@ class Actions { * Mimmics the Axios API using Fetch's Response object. * @param response - The response object. */ - async #wrap(response: Response) { + async #wrap(response: Response): Promise { return { success: response.ok, status_code: response.status, @@ -40,7 +40,7 @@ class Actions { * @param client - The client to use. * @param playlist_id - The playlist ID. */ - async getVideoInfo(id: string, cpn?: string, client?: string, playlist_id?: string) { + async getVideoInfo(id: string, cpn?: string, client?: string, playlist_id?: string): Promise { const data: Record = { playbackContext: { contentPlaybackContext: { @@ -90,7 +90,7 @@ class Actions { * @param client - The client to use. * @param params - Call parameters. */ - async stats(url: string, client: { client_name: string; client_version: string }, params: { [key: string]: any }) { + async stats(url: string, client: { client_name: string; client_version: string }, params: { [key: string]: any }): Promise { const s_url = new URL(url); s_url.searchParams.set('ver', '2'); diff --git a/src/core/Feed.ts b/src/core/Feed.ts index d2e37b08b..7e56c315e 100644 --- a/src/core/Feed.ts +++ b/src/core/Feed.ts @@ -1,41 +1,40 @@ +import type { Memo, ObservedArray, SuperParsedResult, YTNode } from '../parser/helpers'; import Parser, { ParsedResponse, ReloadContinuationItemsCommand } from '../parser/index'; -import { Memo, ObservedArray } from '../parser/helpers'; import { concatMemos, InnertubeError } from '../utils/Utils'; -import Actions from './Actions'; +import type Actions from './Actions'; -import Post from '../parser/classes/Post'; import BackstagePost from '../parser/classes/BackstagePost'; - import Channel from '../parser/classes/Channel'; import CompactVideo from '../parser/classes/CompactVideo'; - import GridChannel from '../parser/classes/GridChannel'; import GridPlaylist from '../parser/classes/GridPlaylist'; import GridVideo from '../parser/classes/GridVideo'; - import Playlist from '../parser/classes/Playlist'; import PlaylistPanelVideo from '../parser/classes/PlaylistPanelVideo'; import PlaylistVideo from '../parser/classes/PlaylistVideo'; - -import Tab from '../parser/classes/Tab'; +import Post from '../parser/classes/Post'; +import ReelItem from '../parser/classes/ReelItem'; import ReelShelf from '../parser/classes/ReelShelf'; import RichShelf from '../parser/classes/RichShelf'; import Shelf from '../parser/classes/Shelf'; +import Tab from '../parser/classes/Tab'; +import Video from '../parser/classes/Video'; +import AppendContinuationItemsAction from '../parser/classes/actions/AppendContinuationItemsAction'; +import ContinuationItem from '../parser/classes/ContinuationItem'; import TwoColumnBrowseResults from '../parser/classes/TwoColumnBrowseResults'; import TwoColumnSearchResults from '../parser/classes/TwoColumnSearchResults'; import WatchCardCompactVideo from '../parser/classes/WatchCardCompactVideo'; -import AppendContinuationItemsAction from '../parser/classes/actions/AppendContinuationItemsAction'; -import ContinuationItem from '../parser/classes/ContinuationItem'; -import Video from '../parser/classes/Video'; -import ReelItem from '../parser/classes/ReelItem'; +import type MusicQueue from '../parser/classes/MusicQueue'; +import type RichGrid from '../parser/classes/RichGrid'; +import type SectionList from '../parser/classes/SectionList'; class Feed { #page: ParsedResponse; #continuation?: ObservedArray; - #actions; - #memo; + #actions: Actions; + #memo: Memo; constructor(actions: Actions, data: any, already_parsed = false) { if (data.on_response_received_actions || data.on_response_received_endpoints || already_parsed) { @@ -117,7 +116,7 @@ class Feed { /** * Returns contents from the page. */ - get page_contents() { + get page_contents(): SectionList | MusicQueue | RichGrid | ReloadContinuationItemsCommand { const tab_content = this.#memo.getType(Tab)?.[0]?.content; const reload_continuation_items = this.#memo.getType(ReloadContinuationItemsCommand)?.[0]; const append_continuation_items = this.#memo.getType(AppendContinuationItemsAction)?.[0]; @@ -136,13 +135,13 @@ class Feed { * Finds shelf by title. */ getShelf(title: string) { - return this.shelves.find((shelf) => shelf.title.toString() === title); + return this.shelves.get({ title }); } /** * Returns secondary contents from the page. */ - get secondary_contents() { + get secondary_contents(): SuperParsedResult | undefined { if (!this.#page.contents.is_node) return undefined; @@ -154,21 +153,21 @@ class Feed { return node.secondary_contents; } - get actions() { + get actions(): Actions { return this.#actions; } /** * Get the original page data */ - get page() { + get page(): ParsedResponse { return this.#page; } /** * Checks if the feed has continuation. */ - get has_continuation() { + get has_continuation(): boolean { return (this.#memo.get('ContinuationItem') || []).length > 0; } @@ -196,7 +195,7 @@ class Feed { /** * Retrieves next batch of contents and returns a new {@link Feed} object. */ - async getContinuation() { + async getContinuation(): Promise { const continuation_data = await this.getContinuationData(); return new Feed(this.actions, continuation_data, true); } diff --git a/src/core/FilterableFeed.ts b/src/core/FilterableFeed.ts index 929d6b92e..f9036547d 100644 --- a/src/core/FilterableFeed.ts +++ b/src/core/FilterableFeed.ts @@ -1,10 +1,11 @@ import ChipCloudChip from '../parser/classes/ChipCloudChip'; import FeedFilterChipBar from '../parser/classes/FeedFilterChipBar'; -import { ObservedArray } from '../parser/helpers'; -import { InnertubeError } from '../utils/Utils'; -import Actions from './Actions'; import Feed from './Feed'; +import type { ObservedArray } from '../parser/helpers'; +import { InnertubeError } from '../utils/Utils'; +import type Actions from './Actions'; + class FilterableFeed extends Feed { #chips?: ObservedArray; @@ -15,7 +16,7 @@ class FilterableFeed extends Feed { /** * Returns the filter chips. */ - get filter_chips() { + get filter_chips(): ObservedArray { if (this.#chips) return this.#chips || []; @@ -33,14 +34,14 @@ class FilterableFeed extends Feed { /** * Returns available filters. */ - get filters() { + get filters(): string[] { return this.filter_chips.map((chip) => chip.text.toString()) || []; } /** * Applies given filter and returns a new {@link Feed} object. */ - async getFilteredFeed(filter: string | ChipCloudChip) { + async getFilteredFeed(filter: string | ChipCloudChip): Promise { let target_filter: ChipCloudChip | undefined; if (typeof filter === 'string') { diff --git a/src/core/InteractionManager.ts b/src/core/InteractionManager.ts index 8d99b7f0d..e90cd7bf0 100644 --- a/src/core/InteractionManager.ts +++ b/src/core/InteractionManager.ts @@ -1,9 +1,10 @@ import Proto from '../proto'; -import Actions from './Actions'; +import type Actions from './Actions'; +import type { ApiResponse } from './Actions'; import { throwIfMissing } from '../utils/Utils'; class InteractionManager { - #actions; + #actions: Actions; constructor(actions: Actions) { this.#actions = actions; @@ -13,7 +14,7 @@ class InteractionManager { * Likes a given video. * @param video_id - The video ID */ - async like(video_id: string) { + async like(video_id: string): Promise { throwIfMissing({ video_id }); if (!this.#actions.session.logged_in) @@ -33,7 +34,7 @@ class InteractionManager { * Dislikes a given video. * @param video_id - The video ID */ - async dislike(video_id: string) { + async dislike(video_id: string): Promise { throwIfMissing({ video_id }); if (!this.#actions.session.logged_in) @@ -53,7 +54,7 @@ class InteractionManager { * Removes a like/dislike. * @param video_id - The video ID */ - async removeRating(video_id: string) { + async removeRating(video_id: string): Promise { throwIfMissing({ video_id }); if (!this.#actions.session.logged_in) @@ -73,7 +74,7 @@ class InteractionManager { * Subscribes to a given channel. * @param channel_id - The channel ID */ - async subscribe(channel_id: string) { + async subscribe(channel_id: string): Promise { throwIfMissing({ channel_id }); if (!this.#actions.session.logged_in) @@ -92,7 +93,7 @@ class InteractionManager { * Unsubscribes from a given channel. * @param channel_id - The channel ID */ - async unsubscribe(channel_id: string) { + async unsubscribe(channel_id: string): Promise{ throwIfMissing({ channel_id }); if (!this.#actions.session.logged_in) @@ -112,7 +113,7 @@ class InteractionManager { * @param video_id - The video ID * @param text - The comment text */ - async comment(video_id: string, text: string) { + async comment(video_id: string, text: string): Promise { throwIfMissing({ video_id, text }); if (!this.#actions.session.logged_in) @@ -159,7 +160,7 @@ class InteractionManager { * @param channel_id - The channel ID. * @param type - The notification type. */ - async setNotificationPreferences(channel_id: string, type: 'PERSONALIZED' | 'ALL' | 'NONE') { + async setNotificationPreferences(channel_id: string, type: 'PERSONALIZED' | 'ALL' | 'NONE'): Promise { throwIfMissing({ channel_id, type }); if (!this.#actions.session.logged_in) diff --git a/src/core/Music.ts b/src/core/Music.ts index 5c3d69d62..18421dda3 100644 --- a/src/core/Music.ts +++ b/src/core/Music.ts @@ -1,34 +1,36 @@ -import Session from './Session'; -import TrackInfo from '../parser/ytmusic/TrackInfo'; -import Search from '../parser/ytmusic/Search'; -import HomeFeed from '../parser/ytmusic/HomeFeed'; +import Album from '../parser/ytmusic/Album'; +import Artist from '../parser/ytmusic/Artist'; import Explore from '../parser/ytmusic/Explore'; +import HomeFeed from '../parser/ytmusic/HomeFeed'; import Library from '../parser/ytmusic/Library'; -import Artist from '../parser/ytmusic/Artist'; -import Album from '../parser/ytmusic/Album'; import Playlist from '../parser/ytmusic/Playlist'; import Recap from '../parser/ytmusic/Recap'; +import Search from '../parser/ytmusic/Search'; +import TrackInfo from '../parser/ytmusic/TrackInfo'; -import Tab from '../parser/classes/Tab'; -import SectionList from '../parser/classes/SectionList'; - +import AutomixPreviewVideo from '../parser/classes/AutomixPreviewVideo'; import Message from '../parser/classes/Message'; +import MusicCarouselShelf from '../parser/classes/MusicCarouselShelf'; +import MusicDescriptionShelf from '../parser/classes/MusicDescriptionShelf'; import MusicQueue from '../parser/classes/MusicQueue'; +import MusicTwoRowItem from '../parser/classes/MusicTwoRowItem'; import PlaylistPanel from '../parser/classes/PlaylistPanel'; -import MusicDescriptionShelf from '../parser/classes/MusicDescriptionShelf'; -import MusicCarouselShelf from '../parser/classes/MusicCarouselShelf'; import SearchSuggestionsSection from '../parser/classes/SearchSuggestionsSection'; -import AutomixPreviewVideo from '../parser/classes/AutomixPreviewVideo'; -import MusicTwoRowItem from '../parser/classes/MusicTwoRowItem'; +import SectionList from '../parser/classes/SectionList'; +import Tab from '../parser/classes/Tab'; -import { observe, ObservedArray, YTNode } from '../parser/helpers'; -import { InnertubeError, throwIfMissing, generateRandomString } from '../utils/Utils'; +import { observe } from '../parser/helpers'; import Proto from '../proto'; +import { generateRandomString, InnertubeError, throwIfMissing } from '../utils/Utils'; + +import type { ObservedArray, YTNode } from '../parser/helpers'; +import type Actions from './Actions'; +import type Session from './Session'; class Music { - #session; - #actions; + #session: Session; + #actions: Actions; constructor(session: Session) { this.#session = session; @@ -49,7 +51,7 @@ class Music { throw new InnertubeError('Invalid target, expected either a video id or a valid MusicTwoRowItem', target); } - async #fetchInfoFromVideoId(video_id: string) { + async #fetchInfoFromVideoId(video_id: string): Promise { const cpn = generateRandomString(16); const initial_info = this.#actions.execute('/player', { @@ -72,7 +74,7 @@ class Music { return new TrackInfo(response, this.#actions, cpn); } - async #fetchInfoFromListItem(list_item: MusicTwoRowItem | undefined) { + async #fetchInfoFromListItem(list_item: MusicTwoRowItem | undefined): Promise { if (!list_item) throw new InnertubeError('List item cannot be undefined'); @@ -339,7 +341,7 @@ class Music { * Retrieves search suggestions for the given query. * @param query - The query. */ - async getSearchSuggestions(query: string) { + async getSearchSuggestions(query: string): Promise> { const response = await this.#actions.execute('/music/get_search_suggestions', { parse: true, input: query, diff --git a/src/core/OAuth.ts b/src/core/OAuth.ts index fd77e1b43..f0882206a 100644 --- a/src/core/OAuth.ts +++ b/src/core/OAuth.ts @@ -1,6 +1,6 @@ -import Session from './Session'; import Constants from '../utils/Constants'; import { OAuthError, uuidv4 } from '../utils/Utils'; +import type Session from './Session'; export interface Credentials { /** @@ -41,7 +41,7 @@ class OAuth { /** * Starts the auth flow in case no valid credentials are available. */ - async init(credentials?: Credentials) { + async init(credentials?: Credentials): Promise { this.#credentials = credentials; if (this.validateCredentials()) { @@ -55,13 +55,13 @@ class OAuth { } } - async cacheCredentials() { + async cacheCredentials(): Promise { const encoder = new TextEncoder(); const data = encoder.encode(JSON.stringify(this.#credentials)); await this.#session.cache?.set('youtubei_oauth_credentials', data.buffer); } - async #loadCachedCredentials() { + async #loadCachedCredentials(): Promise { const data = await this.#session.cache?.get('youtubei_oauth_credentials'); if (!data) return false; @@ -82,14 +82,14 @@ class OAuth { return true; } - async removeCache() { + async removeCache(): Promise { await this.#session.cache?.remove('youtubei_oauth_credentials'); } /** * Asks the server for a user code and verification URL. */ - async #getUserCode() { + async #getUserCode(): Promise { this.#identity = await this.#getClientIdentity(); const data = { @@ -117,7 +117,7 @@ class OAuth { /** * Polls the authorization server until access is granted by the user. */ - #startPolling(device_code: string) { + #startPolling(device_code: string): void { const poller = setInterval(async () => { const data = { ...this.#identity, @@ -176,13 +176,13 @@ class OAuth { /** * Refresh access token if the same has expired. */ - async refreshIfRequired() { + async refreshIfRequired(): Promise { if (this.has_access_token_expired) { await this.#refreshAccessToken(); } } - async #refreshAccessToken() { + async #refreshAccessToken(): Promise { if (!this.#credentials) return; this.#identity = await this.#getClientIdentity(); @@ -215,7 +215,7 @@ class OAuth { }); } - async revokeCredentials() { + async revokeCredentials(): Promise { if (!this.#credentials) return; await this.removeCache(); return this.#session.http.fetch_function(new URL(`/o/oauth2/revoke?token=${encodeURIComponent(this.#credentials.access_token)}`, Constants.URLS.YT_BASE), { @@ -226,7 +226,7 @@ class OAuth { /** * Retrieves client identity from YouTube TV. */ - async #getClientIdentity() { + async #getClientIdentity(): Promise<{ [key: string]: string; }> { 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(); @@ -249,7 +249,7 @@ class OAuth { return groups; } - get credentials() { + get credentials(): Credentials | undefined { return this.#credentials; } diff --git a/src/core/Player.ts b/src/core/Player.ts index 101ec4da3..d9e62be17 100644 --- a/src/core/Player.ts +++ b/src/core/Player.ts @@ -1,13 +1,13 @@ - -import { FetchFunction } from '../utils/HTTPClient'; import { getRandomUserAgent, getStringBetweenStrings, PlayerError } from '../utils/Utils'; import Constants from '../utils/Constants'; import UniversalCache from '../utils/Cache'; -// See https://github.com/LuanRT/Jinter +// See: https://github.com/LuanRT/Jinter import Jinter from 'jintr'; +import type { FetchFunction } from '../utils/HTTPClient'; + export default class Player { #nsig_sc; #sig_sc; @@ -23,7 +23,7 @@ export default class Player { this.#player_id = player_id; } - static async create(cache: UniversalCache | undefined, fetch: FetchFunction = globalThis.fetch) { + static async create(cache: UniversalCache | undefined, fetch: FetchFunction = globalThis.fetch): Promise { const url = new URL('/iframe_api', Constants.URLS.YT_BASE); const res = await fetch(url); @@ -66,7 +66,7 @@ export default class Player { return await Player.fromSource(cache, sig_timestamp, sig_sc, nsig_sc, player_id); } - decipher(url?: string, signature_cipher?: string, cipher?: string) { + decipher(url?: string, signature_cipher?: string, cipher?: string): string { url = url || signature_cipher || cipher; if (!url) @@ -108,7 +108,7 @@ export default class Player { return url_components.toString(); } - static async fromCache(cache: UniversalCache, player_id: string) { + static async fromCache(cache: UniversalCache, player_id: string): Promise { const buffer = await cache.get(player_id); if (!buffer) @@ -134,13 +134,13 @@ export default class Player { return new Player(sig_timestamp, sig_sc, nsig_sc, player_id); } - static async fromSource(cache: UniversalCache | undefined, sig_timestamp: number, sig_sc: string, nsig_sc: string, player_id: string) { + static async fromSource(cache: UniversalCache | undefined, sig_timestamp: number, sig_sc: string, nsig_sc: string, player_id: string): Promise { const player = new Player(sig_timestamp, sig_sc, nsig_sc, player_id); await player.cache(cache); return player; } - async cache(cache?: UniversalCache) { + async cache(cache?: UniversalCache): Promise { if (!cache) return; const encoder = new TextEncoder(); @@ -161,11 +161,11 @@ export default class Player { await cache.set(this.#player_id, new Uint8Array(buffer)); } - static extractSigTimestamp(data: string) { + static extractSigTimestamp(data: string): number { return parseInt(getStringBetweenStrings(data, 'signatureTimestamp:', ',') || '0'); } - static extractSigSourceCode(data: string) { + static extractSigSourceCode(data: string): string { const calls = getStringBetweenStrings(data, 'function(a){a=a.split("")', 'return a.join("")}'); const obj_name = calls?.split(/\.|\[/)?.[0]?.replace(';', '')?.trim(); const functions = getStringBetweenStrings(data, `var ${obj_name}={`, '};'); @@ -176,7 +176,7 @@ export default class Player { return `function descramble_sig(a) { a = a.split(""); let ${obj_name}={${functions}}${calls} return a.join("") } descramble_sig(sig);`; } - static extractNSigSourceCode(data: string) { + static extractNSigSourceCode(data: string): string { const sc = `function descramble_nsig(a) { let b=a.split("")${getStringBetweenStrings(data, 'b=a.split("")', '}return b.join("")}')}} return b.join(""); } descramble_nsig(nsig)`; if (!sc) @@ -185,23 +185,23 @@ export default class Player { return sc; } - get url() { + get url(): string { return new URL(`/s/player/${this.#player_id}/player_ias.vflset/en_US/base.js`, Constants.URLS.YT_BASE).toString(); } - get sts() { + get sts(): number { return this.#sig_sc_timestamp; } - get nsig_sc() { + get nsig_sc(): string { return this.#nsig_sc; } - get sig_sc() { + get sig_sc(): string { return this.#sig_sc; } - static get LIBRARY_VERSION() { + static get LIBRARY_VERSION(): number { return 2; } } \ No newline at end of file diff --git a/src/core/PlaylistManager.ts b/src/core/PlaylistManager.ts index 362c3f29c..51e9fd6c9 100644 --- a/src/core/PlaylistManager.ts +++ b/src/core/PlaylistManager.ts @@ -1,11 +1,11 @@ +import type Feed from './Feed'; +import type Actions from './Actions'; import Playlist from '../parser/youtube/Playlist'; -import Actions from './Actions'; -import Feed from './Feed'; import { InnertubeError, throwIfMissing } from '../utils/Utils'; class PlaylistManager { - #actions; + #actions: Actions; constructor(actions: Actions) { this.#actions = actions; @@ -16,7 +16,7 @@ class PlaylistManager { * @param title - The title of the playlist. * @param video_ids - An array of video IDs to add to the playlist. */ - async create(title: string, video_ids: string[]) { + async create(title: string, video_ids: string[]): Promise<{ success: boolean; status_code: number; playlist_id: string; data: any }> { throwIfMissing({ title, video_ids }); if (!this.#actions.session.logged_in) @@ -40,7 +40,7 @@ class PlaylistManager { * Deletes a given playlist. * @param playlist_id - The playlist ID. */ - async delete(playlist_id: string) { + async delete(playlist_id: string): Promise<{ playlist_id: string; success: boolean; status_code: number; data: any }> { throwIfMissing({ playlist_id }); if (!this.#actions.session.logged_in) @@ -61,7 +61,7 @@ class PlaylistManager { * @param playlist_id - The playlist ID. * @param video_ids - An array of video IDs to add to the playlist. */ - async addVideos(playlist_id: string, video_ids: string[]) { + async addVideos(playlist_id: string, video_ids: string[]): Promise<{ playlist_id: string; action_result: any }> { throwIfMissing({ playlist_id, video_ids }); if (!this.#actions.session.logged_in) @@ -87,7 +87,7 @@ class PlaylistManager { * @param playlist_id - The playlist ID. * @param video_ids - An array of video IDs to remove from the playlist. */ - async removeVideos(playlist_id: string, video_ids: string[]) { + async removeVideos(playlist_id: string, video_ids: string[]): Promise<{ playlist_id: string; action_result: any }> { throwIfMissing({ playlist_id, video_ids }); if (!this.#actions.session.logged_in) @@ -146,7 +146,7 @@ class PlaylistManager { * @param moved_video_id - The video ID to move. * @param predecessor_video_id - The video ID to move the moved video before. */ - async moveVideo(playlist_id: string, moved_video_id: string, predecessor_video_id: string) { + async moveVideo(playlist_id: string, moved_video_id: string, predecessor_video_id: string): Promise<{ playlist_id: string; action_result: any; }> { throwIfMissing({ playlist_id, moved_video_id, predecessor_video_id }); if (!this.#actions.session.logged_in) diff --git a/src/core/Session.ts b/src/core/Session.ts index 7c52a05e3..9adf15f4b 100644 --- a/src/core/Session.ts +++ b/src/core/Session.ts @@ -1,8 +1,8 @@ -import Player from './Player'; -import Actions from './Actions'; -import Constants from '../utils/Constants'; import UniversalCache from '../utils/Cache'; +import Constants from '../utils/Constants'; import EventEmitterLike from '../utils/EventEmitterLike'; +import Actions from './Actions'; +import Player from './Player'; import HTTPClient, { FetchFunction } from '../utils/HTTPClient'; import { DeviceCategory, getRandomUserAgent, InnertubeError, SessionError } from '../utils/Utils'; @@ -12,7 +12,9 @@ export enum ClientType { WEB = 'WEB', MUSIC = 'WEB_REMIX', ANDROID = 'ANDROID', - ANDROID_MUSIC = 'ANDROID_MUSIC' + ANDROID_MUSIC = 'ANDROID_MUSIC', + ANDROID_CREATOR = 'ANDROID_CREATOR', + TV_EMBEDDED = 'TVHTML5_SIMPLY_EMBEDDED_PLAYER' } export interface Context { @@ -73,11 +75,11 @@ export default class Session extends EventEmitterLike { #account_index; #player; - oauth; - http; - logged_in; - actions; - cache; + oauth: OAuth; + http: HTTPClient; + logged_in: boolean; + actions: Actions; + cache?: UniversalCache; constructor(context: Context, api_key: string, api_version: string, account_index: number, player?: Player, cookie?: string, fetch?: FetchFunction, cache?: UniversalCache) { super(); @@ -225,7 +227,10 @@ export default class Session extends EventEmitterLike { }); } - async signOut() { + /** + * Signs out of the current account and revokes the credentials. + */ + async signOut(): Promise { if (!this.logged_in) throw new InnertubeError('You must be signed in to perform this operation.'); @@ -235,35 +240,41 @@ export default class Session extends EventEmitterLike { return response; } - get key() { + /** + * InnerTube API key. + */ + get key(): string { return this.#key; } - get api_version() { + /** + * InnerTube API version. + */ + get api_version(): string { return this.#api_version; } - get client_version() { + get client_version(): string { return this.#context.client.clientVersion; } - get client_name() { + get client_name(): string { return this.#context.client.clientName; } - get account_index() { + get account_index(): number { return this.#account_index; } - get context() { + get context(): Context { return this.#context; } - get player() { + get player(): Player | undefined { return this.#player; } - get lang() { + get lang(): string { return this.#context.client.hl; } } \ No newline at end of file diff --git a/src/core/Studio.ts b/src/core/Studio.ts index 7e5b2a121..e271bcfde 100644 --- a/src/core/Studio.ts +++ b/src/core/Studio.ts @@ -1,8 +1,9 @@ import Proto from '../proto'; -import Session from './Session'; -import { ApiResponse } from './Actions'; -import { InnertubeError, MissingParamError, uuidv4 } from '../utils/Utils'; import { Constants } from '../utils'; +import { InnertubeError, MissingParamError, uuidv4 } from '../utils/Utils'; + +import type { ApiResponse } from './Actions'; +import type Session from './Session'; interface UploadResult { status: string; @@ -36,7 +37,7 @@ export interface UploadedVideoMetadata { } class Studio { - #session; + #session: Session; constructor(session: Session) { this.#session = session; @@ -81,7 +82,7 @@ class Studio { * }); * ``` */ - async updateVideoMetadata(video_id: string, metadata: VideoMetadata) { + async updateVideoMetadata(video_id: string, metadata: VideoMetadata): Promise { if (!this.#session.logged_in) throw new InnertubeError('You must be signed in to perform this operation.'); diff --git a/src/core/TabbedFeed.ts b/src/core/TabbedFeed.ts index ceda6b24a..80a59fff2 100644 --- a/src/core/TabbedFeed.ts +++ b/src/core/TabbedFeed.ts @@ -1,11 +1,13 @@ import Tab from '../parser/classes/Tab'; -import { InnertubeError } from '../utils/Utils'; -import Actions from './Actions'; import Feed from './Feed'; +import { InnertubeError } from '../utils/Utils'; + +import type Actions from './Actions'; +import type { ObservedArray } from '../parser/helpers'; class TabbedFeed extends Feed { - #tabs; - #actions; + #tabs: ObservedArray; + #actions: Actions; constructor(actions: Actions, data: any, already_parsed = false) { super(actions, data, already_parsed); @@ -13,11 +15,11 @@ class TabbedFeed extends Feed { this.#tabs = this.page.contents_memo.getType(Tab); } - get tabs() { + get tabs(): string[] { return this.#tabs.map((tab) => tab.title.toString()); } - async getTabByName(title: string) { + async getTabByName(title: string): Promise { const tab = this.#tabs.find((tab) => tab.title.toLowerCase() === title.toLowerCase()); if (!tab) @@ -31,7 +33,7 @@ class TabbedFeed extends Feed { return new TabbedFeed(this.#actions, response.data, false); } - async getTabByURL(url: string) { + async getTabByURL(url: string): Promise { const tab = this.#tabs.find((tab) => tab.endpoint.metadata.url?.split('/').pop() === url); if (!tab) @@ -45,7 +47,7 @@ class TabbedFeed extends Feed { return new TabbedFeed(this.#actions, response.data, false); } - get title() { + get title(): string | undefined { return this.page.contents_memo.getType(Tab)?.find((tab) => tab.selected)?.title.toString(); } } diff --git a/src/parser/classes/PlaylistSidebarPrimaryInfo.ts b/src/parser/classes/PlaylistSidebarPrimaryInfo.ts index 2a8a28847..56f0eec16 100644 --- a/src/parser/classes/PlaylistSidebarPrimaryInfo.ts +++ b/src/parser/classes/PlaylistSidebarPrimaryInfo.ts @@ -19,7 +19,7 @@ class PlaylistSidebarPrimaryInfo extends YTNode { this.stats = data.stats.map((stat: any) => new Text(stat)); this.thumbnail_renderer = Parser.parse(data.thumbnailRenderer); this.title = new Text(data.title); - this.menu = data.menu && Parser.parse(data.menu); + this.menu = Parser.parseItem(data.menu); this.endpoint = new NavigationEndpoint(data.navigationEndpoint); this.description = new Text(data.description); } diff --git a/src/parser/classes/SectionList.ts b/src/parser/classes/SectionList.ts index e9dc3b4a3..cd1432f2a 100644 --- a/src/parser/classes/SectionList.ts +++ b/src/parser/classes/SectionList.ts @@ -4,9 +4,9 @@ import { YTNode } from '../helpers'; class SectionList extends YTNode { static type = 'SectionList'; - target_id; + target_id?: string; contents; - continuation; + continuation?: string; header; constructor(data: any) { diff --git a/src/parser/classes/Shelf.ts b/src/parser/classes/Shelf.ts index e0786f51a..f20f8e0d8 100644 --- a/src/parser/classes/Shelf.ts +++ b/src/parser/classes/Shelf.ts @@ -7,10 +7,10 @@ class Shelf extends YTNode { static type = 'Shelf'; title: Text; - endpoint; - content; - icon_type; - menu; + endpoint?: NavigationEndpoint; + content: YTNode | null; + icon_type?: string; + menu?: YTNode | null; constructor(data: any) { super(); diff --git a/src/parser/classes/comments/CommentsEntryPointHeader.ts b/src/parser/classes/comments/CommentsEntryPointHeader.ts index ca8740936..ee34f1ca4 100644 --- a/src/parser/classes/comments/CommentsEntryPointHeader.ts +++ b/src/parser/classes/comments/CommentsEntryPointHeader.ts @@ -5,19 +5,34 @@ import { YTNode } from '../../helpers'; class CommentsEntryPointHeader extends YTNode { static type = 'CommentsEntryPointHeader'; - header; - comment_count; - teaser_avatar; - teaser_content; - simplebox_placeholder; + header?: Text; + comment_count?: Text; + teaser_avatar?: Thumbnail[]; + teaser_content?: Text; + simplebox_placeholder?: Text; constructor(data: any) { super(); - this.header = new Text(data.headerText); - this.comment_count = new Text(data.commentCount); - this.teaser_avatar = Thumbnail.fromResponse(data.teaserAvatar || data.simpleboxAvatar); - this.teaser_content = new Text(data.teaserContent); - this.simplebox_placeholder = new Text(data.simpleboxPlaceholder); + + if (data.header) { + this.header = new Text(data.headerText); + } + + if (data.commentCount) { + this.comment_count = new Text(data.commentCount); + } + + if (data.teaserAvatar || data.simpleboxAvatar) { + this.teaser_avatar = Thumbnail.fromResponse(data.teaserAvatar || data.simpleboxAvatar); + } + + if (data.teaserContent) { + this.teaser_content = new Text(data.teaserContent); + } + + if (data.simpleboxPlaceholder) { + this.simplebox_placeholder = new Text(data.simpleboxPlaceholder); + } } } diff --git a/src/parser/helpers.ts b/src/parser/helpers.ts index f6891cd63..a3f8e6b9f 100644 --- a/src/parser/helpers.ts +++ b/src/parser/helpers.ts @@ -385,7 +385,7 @@ export type ObservedArray = Array & { * Creates a trap to intercept property access * and add utilities to an object. */ -export function observe(obj: Array) { +export function observe(obj: Array): ObservedArray { return new Proxy(obj, { get(target, prop) { if (prop == 'get') { diff --git a/src/parser/index.ts b/src/parser/index.ts index 571ab656d..edb225bb0 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -1,176 +1,21 @@ +import type AudioOnlyPlayability from './classes/AudioOnlyPlayability'; +import type CardCollection from './classes/CardCollection'; +import type Endscreen from './classes/Endscreen'; +import type PlayerAnnotationsExpanded from './classes/PlayerAnnotationsExpanded'; +import type PlayerCaptionsTracklist from './classes/PlayerCaptionsTracklist'; +import type PlayerLiveStoryboardSpec from './classes/PlayerLiveStoryboardSpec'; +import type PlayerStoryboardSpec from './classes/PlayerStoryboardSpec'; + +import MusicMultiSelectMenuItem from './classes/menus/MusicMultiSelectMenuItem'; import Format from './classes/misc/Format'; import VideoDetails from './classes/misc/VideoDetails'; -import GetParserByName from './map'; -import Endscreen from './classes/Endscreen'; -import CardCollection from './classes/CardCollection'; import NavigationEndpoint from './classes/NavigationEndpoint'; -import PlayerStoryboardSpec from './classes/PlayerStoryboardSpec'; -import PlayerCaptionsTracklist from './classes/PlayerCaptionsTracklist'; -import PlayerLiveStoryboardSpec from './classes/PlayerLiveStoryboardSpec'; -import PlayerAnnotationsExpanded from './classes/PlayerAnnotationsExpanded'; - import { InnertubeError, ParsingError } from '../utils/Utils'; -import { YTNode, YTNodeConstructor, SuperParsedResult, ObservedArray, observe, Memo } from './helpers'; +import { Memo, observe, ObservedArray, SuperParsedResult, YTNode, YTNodeConstructor } from './helpers'; +import GetParserByName from './map'; import package_json from '../../package.json'; -import MusicMultiSelectMenuItem from './classes/menus/MusicMultiSelectMenuItem'; -import AudioOnlyPlayability from './classes/AudioOnlyPlayability'; - -export class AppendContinuationItemsAction extends YTNode { - static readonly type = 'appendContinuationItemsAction'; - - contents: ObservedArray | null; - - constructor(data: any) { - super(); - 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 || - data.continuations?.[0]?.reloadContinuationData?.continuation || null; - } -} - -export class MusicPlaylistShelfContinuation extends YTNode { - static readonly type = 'musicPlaylistShelfContinuation'; - - continuation: string; - contents: ObservedArray | null; - - constructor(data: any) { - super(); - this.contents = Parser.parse(data.contents, true); - this.continuation = data.continuations?.[0].nextContinuationData.continuation || null; - } -} - -export class MusicShelfContinuation extends YTNode { - static readonly type = 'musicShelfContinuation'; - - continuation: string; - contents: ObservedArray | null; - - constructor(data: any) { - super(); - this.contents = Parser.parse(data.contents, true); - this.continuation = - data.continuations?.[0].nextContinuationData?.continuation || - data.continuations?.[0].reloadContinuationData?.continuation || null; - } -} - -export class GridContinuation extends YTNode { - static readonly type = 'gridContinuation'; - - continuation: string; - items: ObservedArray | null; - - constructor(data: any) { - super(); - this.items = Parser.parse(data.items, true); - this.continuation = data.continuations?.[0].nextContinuationData.continuation || null; - } - - get contents() { - return this.items; - } -} - -export class PlaylistPanelContinuation extends YTNode { - static readonly type = 'playlistPanelContinuation'; - - continuation: string; - contents: ObservedArray | null; - - constructor(data: any) { - super(); - this.contents = Parser.parse(data.contents, true); - this.continuation = data.continuations?.[0]?.nextContinuationData?.continuation || - data.continuations?.[0]?.nextRadioContinuationData?.continuation || null; - } -} - -export class TimedContinuation extends YTNode { - static readonly type = 'timedContinuationData'; - - 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; - header: YTNode | null; - participants_list: YTNode | null; - popout_message: YTNode | null; - 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, - search_terms: emoji.searchTerms, - 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.viewer_name = data.viewerName; - } -} export type ParserError = { classname: string, classdata: any, err: any }; export type ParserErrorHandler = (error: ParserError) => void; @@ -210,12 +55,12 @@ export default class Parser { /** * Parses InnerTube response. + * @param data - The response data. */ static parseResponse(data: any) { // Memoize the response objects by classname - this.#createMemo(); - // TODO: is this parseItem? + // TODO: should this parseItem? const contents = Parser.parse(data.contents); const contents_memo = this.#getMemo(); this.#clearMemo(); @@ -257,7 +102,6 @@ export default class Parser { const sidebar_memo = this.#getMemo(); this.#clearMemo(); - this.applyMutations(contents_memo, data.frameworkUpdates?.entityBatchUpdate?.mutations); return { @@ -286,13 +130,13 @@ export default class Parser { estimated_results: data.estimatedResults ? parseInt(data.estimatedResults) : null, player_overlays: Parser.parse(data.playerOverlays), playback_tracking: data.playbackTracking ? { - videostats_watchtime_url: data.playbackTracking.videostatsWatchtimeUrl.baseUrl, - videostats_playback_url: data.playbackTracking.videostatsPlaybackUrl.baseUrl + videostats_watchtime_url: data.playbackTracking.videostatsWatchtimeUrl.baseUrl as string, + videostats_playback_url: data.playbackTracking.videostatsPlaybackUrl.baseUrl as string } : null, playability_status: data.playabilityStatus ? { status: data.playabilityStatus.status as string, error_screen: Parser.parseItem(data.playabilityStatus.errorScreen), - audio_only_playablility: Parser.parseItem(data.playabilityStatus.audioOnlyPlayability, AudioOnlyPlayability), + audio_only_playablility: Parser.parseItem(data.playabilityStatus.audioOnlyPlayability), embeddable: !!data.playabilityStatus.playableInEmbed || false, reason: data.playabilityStatus?.reason || '' } : undefined, @@ -300,64 +144,26 @@ export default class Parser { expires: new Date(Date.now() + parseInt(data.streamingData.expiresInSeconds) * 1000), formats: Parser.parseFormats(data.streamingData.formats), adaptive_formats: Parser.parseFormats(data.streamingData.adaptiveFormats), - dash_manifest_url: data.streamingData?.dashManifestUrl || null, - hls_manifest_url: data.streamingData?.hlsManifestUrl || null + dash_manifest_url: data.streamingData?.dashManifestUrl as string || null, + hls_manifest_url: data.streamingData?.hlsManifestUrl as string || null } : undefined, current_video_endpoint: data.currentVideoEndpoint ? new NavigationEndpoint(data.currentVideoEndpoint) : null, - captions: Parser.parseItem(data.captions, PlayerCaptionsTracklist), + captions: Parser.parseItem(data.captions), video_details: data.videoDetails ? new VideoDetails(data.videoDetails) : undefined, - annotations: Parser.parseArray(data.annotations, PlayerAnnotationsExpanded), - storyboards: Parser.parseItem(data.storyboards, [ PlayerStoryboardSpec, PlayerLiveStoryboardSpec ]), - endscreen: Parser.parseItem(data.endscreen, Endscreen), - cards: Parser.parseItem(data.cards, CardCollection) + annotations: Parser.parseArray(data.annotations), + storyboards: Parser.parseItem(data.storyboards), + endscreen: Parser.parseItem(data.endscreen), + cards: Parser.parseItem(data.cards) }; } - 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); - if (data.musicPlaylistShelfContinuation) - return new MusicPlaylistShelfContinuation(data.musicPlaylistShelfContinuation); - if (data.musicShelfContinuation) - return new MusicShelfContinuation(data.musicShelfContinuation); - if (data.gridContinuation) - return new GridContinuation(data.gridContinuation); - if (data.playlistPanelContinuation) - return new PlaylistPanelContinuation(data.playlistPanelContinuation); - } - - static parseRR(actions: any[]) { - return observe(actions.map((action: any) => { - if (action.reloadContinuationItemsCommand) - return new ReloadContinuationItemsCommand(action.reloadContinuationItemsCommand); - if (action.appendContinuationItemsAction) - 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) => { - delete action.clickTrackingParams; - return action; - })); - } - return new SuperParsedResult(Parser.parseItem(data)); - } - - static parseFormats(formats: any[]) { - return formats?.map((format) => new Format(format)) || []; - } - + /** + * Parses a single item. + * @param data - The data to parse. + * @param validTypes - YTNode types that are allowed to be parsed. + */ static parseItem(data: any, validTypes?: YTNodeConstructor | YTNodeConstructor[]) { - if (!data ) return null; + if (!data) return null; const keys = Object.keys(data); @@ -391,6 +197,11 @@ export default class Parser { return null; } + /** + * Parses an array of items. + * @param data - The data to parse. + * @param validTypes - YTNode types that are allowed to be parsed. + */ static parseArray(data: any[], validTypes?: YTNodeConstructor | YTNodeConstructor[]) { if (Array.isArray(data)) { const results: T[] = []; @@ -409,6 +220,12 @@ export default class Parser { throw new ParsingError('Expected array but got a single item'); } + /** + * Parses an item or an array of items. + * @param data - The data to parse. + * @param requireArray - Whether the data should be parsed as an array. + * @param validTypes - YTNode types that are allowed to be parsed. + */ static parse(data: any, requireArray: true, validTypes?: YTNodeConstructor | YTNodeConstructor[]): ObservedArray | null; static parse(data: any, requireArray?: false | undefined, validTypes?: YTNodeConstructor | YTNodeConstructor[]): SuperParsedResult; static parse(data: any, requireArray?: boolean, validTypes?: YTNodeConstructor | YTNodeConstructor[]) { @@ -434,6 +251,49 @@ export default class Parser { return new SuperParsedResult(this.parseItem(data, validTypes)); } + 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); + if (data.musicPlaylistShelfContinuation) + return new MusicPlaylistShelfContinuation(data.musicPlaylistShelfContinuation); + if (data.musicShelfContinuation) + return new MusicShelfContinuation(data.musicShelfContinuation); + if (data.gridContinuation) + return new GridContinuation(data.gridContinuation); + if (data.playlistPanelContinuation) + return new PlaylistPanelContinuation(data.playlistPanelContinuation); + } + + static parseRR(actions: any[]) { + return observe(actions.map((action: any) => { + if (action.reloadContinuationItemsCommand) + return new ReloadContinuationItemsCommand(action.reloadContinuationItemsCommand); + if (action.appendContinuationItemsAction) + 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) => { + delete action.clickTrackingParams; + return action; + })); + } + return new SuperParsedResult(Parser.parseItem(data)); + } + + static parseFormats(formats: any[]) { + return formats?.map((format) => new Format(format)) || []; + } + static applyMutations(memo: Memo, mutations: Array) { // Apply mutations to MusicMultiSelectMenuItems const music_multi_select_menu_items = memo.getType(MusicMultiSelectMenuItem); @@ -513,4 +373,161 @@ export default class Parser { } } -export type ParsedResponse = ReturnType; \ No newline at end of file +export type ParsedResponse = ReturnType; + +// Continuation nodes + +export class AppendContinuationItemsAction extends YTNode { + static readonly type = 'appendContinuationItemsAction'; + + contents: ObservedArray | null; + + constructor(data: any) { + super(); + 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 || + data.continuations?.[0]?.reloadContinuationData?.continuation || null; + } +} + +export class MusicPlaylistShelfContinuation extends YTNode { + static readonly type = 'musicPlaylistShelfContinuation'; + + continuation: string; + contents: ObservedArray | null; + + constructor(data: any) { + super(); + this.contents = Parser.parse(data.contents, true); + this.continuation = data.continuations?.[0].nextContinuationData.continuation || null; + } +} + +export class MusicShelfContinuation extends YTNode { + static readonly type = 'musicShelfContinuation'; + + continuation: string; + contents: ObservedArray | null; + + constructor(data: any) { + super(); + this.contents = Parser.parse(data.contents, true); + this.continuation = + data.continuations?.[0].nextContinuationData?.continuation || + data.continuations?.[0].reloadContinuationData?.continuation || null; + } +} + +export class GridContinuation extends YTNode { + static readonly type = 'gridContinuation'; + + continuation: string; + items: ObservedArray | null; + + constructor(data: any) { + super(); + this.items = Parser.parse(data.items, true); + this.continuation = data.continuations?.[0].nextContinuationData.continuation || null; + } + + get contents() { + return this.items; + } +} + +export class PlaylistPanelContinuation extends YTNode { + static readonly type = 'playlistPanelContinuation'; + + continuation: string; + contents: ObservedArray | null; + + constructor(data: any) { + super(); + this.contents = Parser.parse(data.contents, true); + this.continuation = data.continuations?.[0]?.nextContinuationData?.continuation || + data.continuations?.[0]?.nextRadioContinuationData?.continuation || null; + } +} + +export class TimedContinuation extends YTNode { + static readonly type = 'timedContinuationData'; + + 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; + header: YTNode | null; + participants_list: YTNode | null; + popout_message: YTNode | null; + 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, + search_terms: emoji.searchTerms, + 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.viewer_name = data.viewerName; + } +} \ No newline at end of file diff --git a/src/parser/youtube/AccountInfo.ts b/src/parser/youtube/AccountInfo.ts index fb2523732..eb72c7097 100644 --- a/src/parser/youtube/AccountInfo.ts +++ b/src/parser/youtube/AccountInfo.ts @@ -1,12 +1,14 @@ import Parser, { ParsedResponse } from '..'; -import { ApiResponse } from '../../core/Actions'; +import type { ApiResponse } from '../../core/Actions'; import AccountSectionList from '../classes/AccountSectionList'; import AccountItemSection from '../classes/AccountItemSection'; import AccountChannel from '../classes/AccountChannel'; +import { InnertubeError } from '../../utils/Utils'; + class AccountInfo { - #page; + #page: ParsedResponse; contents: AccountItemSection | null; footers: AccountChannel | null; @@ -14,7 +16,10 @@ class AccountInfo { constructor(response: ApiResponse) { this.#page = Parser.parseResponse(response.data); - const account_section_list = this.#page.contents.array().as(AccountSectionList)[0]; + const account_section_list = this.#page.contents.array().as(AccountSectionList)?.[0]; + + if (!account_section_list) + throw new InnertubeError('Account section list not found'); this.contents = account_section_list.contents; this.footers = account_section_list.footers; diff --git a/src/parser/youtube/Analytics.ts b/src/parser/youtube/Analytics.ts index e149e905d..093132c6c 100644 --- a/src/parser/youtube/Analytics.ts +++ b/src/parser/youtube/Analytics.ts @@ -1,15 +1,14 @@ import Parser, { ParsedResponse } from '..'; -import { ApiResponse } from '../../core/Actions'; +import type { ApiResponse } from '../../core/Actions'; import Element from '../classes/Element'; class Analytics { - #page; + #page: ParsedResponse; sections; constructor(response: ApiResponse) { this.#page = Parser.parseResponse(response.data); - this.sections = this.#page.contents_memo?.get('Element') - ?.map((el) => el.as(Element).model?.item()); + this.sections = this.#page.contents_memo?.getType(Element).map((el) => el.model?.item()); } get page(): ParsedResponse { diff --git a/src/parser/youtube/Channel.ts b/src/parser/youtube/Channel.ts index 4ea4d6692..059c6aa48 100644 --- a/src/parser/youtube/Channel.ts +++ b/src/parser/youtube/Channel.ts @@ -1,27 +1,28 @@ -import Actions from '../../core/Actions'; +import type Actions from '../../core/Actions'; import TabbedFeed from '../../core/TabbedFeed'; import C4TabbedHeader from '../classes/C4TabbedHeader'; import CarouselHeader from '../classes/CarouselHeader'; -import InteractiveTabbedHeader from '../classes/InteractiveTabbedHeader'; import ChannelAboutFullMetadata from '../classes/ChannelAboutFullMetadata'; import ChannelMetadata from '../classes/ChannelMetadata'; +import InteractiveTabbedHeader from '../classes/InteractiveTabbedHeader'; import MicroformatData from '../classes/MicroformatData'; import SubscribeButton from '../classes/SubscribeButton'; import Tab from '../classes/Tab'; -import FeedFilterChipBar from '../classes/FeedFilterChipBar'; -import ChipCloudChip from '../classes/ChipCloudChip'; -import FilterableFeed from '../../core/FilterableFeed'; import Feed from '../../core/Feed'; - -import { InnertubeError } from '../../utils/Utils'; +import FilterableFeed from '../../core/FilterableFeed'; +import ChipCloudChip from '../classes/ChipCloudChip'; import ExpandableTab from '../classes/ExpandableTab'; +import FeedFilterChipBar from '../classes/FeedFilterChipBar'; +import { InnertubeError } from '../../utils/Utils'; + +import type { AppendContinuationItemsAction, ReloadContinuationItemsCommand } from '..'; export default class Channel extends TabbedFeed { - header; + header?: C4TabbedHeader | CarouselHeader | InteractiveTabbedHeader; metadata; - subscribe_button; - current_tab; + subscribe_button?: SubscribeButton; + current_tab?: Tab | ExpandableTab; constructor(actions: Actions, data: any, already_parsed = false) { super(actions, data, already_parsed); @@ -42,10 +43,10 @@ export default class Channel extends TabbedFeed { } /** - * Applies given filter to the list. + * Applies given filter to the list. Use {@link filters} to get available filters. * @param filter - The filter to apply */ - async applyFilter(filter: string | ChipCloudChip) { + async applyFilter(filter: string | ChipCloudChip): Promise { let target_filter: ChipCloudChip | undefined; const filter_chipbar = this.memo.getType(FeedFilterChipBar)?.[0]; @@ -69,37 +70,37 @@ export default class Channel extends TabbedFeed { return this.memo.getType(FeedFilterChipBar)?.[0]?.contents.filterType(ChipCloudChip).map((chip) => chip.text) || []; } - async getHome() { + async getHome(): Promise { const tab = await this.getTabByURL('featured'); return new Channel(this.actions, tab.page, true); } - async getVideos() { + async getVideos(): Promise { const tab = await this.getTabByURL('videos'); return new Channel(this.actions, tab.page, true); } - async getShorts() { + async getShorts(): Promise { const tab = await this.getTabByURL('shorts'); return new Channel(this.actions, tab.page, true); } - async getLiveStreams() { + async getLiveStreams(): Promise { const tab = await this.getTabByURL('streams'); return new Channel(this.actions, tab.page, true); } - async getPlaylists() { + async getPlaylists(): Promise { const tab = await this.getTabByURL('playlists'); return new Channel(this.actions, tab.page, true); } - async getCommunity() { + async getCommunity(): Promise { const tab = await this.getTabByURL('community'); return new Channel(this.actions, tab.page, true); } - async getChannels() { + async getChannels(): Promise { const tab = await this.getTabByURL('channels'); return new Channel(this.actions, tab.page, true); } @@ -108,7 +109,7 @@ export default class Channel extends TabbedFeed { * Retrieves the channel about page. * Note that this does not return a new {@link Channel} object. */ - async getAbout() { + async getAbout(): Promise { const tab = await this.getTabByURL('about'); return tab.memo.getType(ChannelAboutFullMetadata)?.[0]; } @@ -116,7 +117,7 @@ export default class Channel extends TabbedFeed { /** * Searches within the channel. */ - async search(query: string) { + async search(query: string): Promise { const tab = this.memo.getType(ExpandableTab)?.[0]; if (!tab) @@ -130,14 +131,14 @@ export default class Channel extends TabbedFeed { /** * Retrives list continuation. */ - async getContinuation() { + async getContinuation(): Promise { const page = await super.getContinuationData(); return new ChannelListContinuation(this.actions, page, true); } } export class ChannelListContinuation extends Feed { - contents; + contents: ReloadContinuationItemsCommand | AppendContinuationItemsAction | undefined; constructor(actions: Actions, data: any, already_parsed = false) { super(actions, data, already_parsed); @@ -149,14 +150,14 @@ export class ChannelListContinuation extends Feed { /** * Retrieves list continuation. */ - async getContinuation() { + async getContinuation(): Promise { const page = await super.getContinuationData(); return new ChannelListContinuation(this.actions, page, true); } } export class FilteredChannelList extends FilterableFeed { - applied_filter: ChipCloudChip | undefined; + applied_filter?: ChipCloudChip; contents; constructor(actions: Actions, data: any, already_parsed = false) { @@ -179,7 +180,7 @@ export class FilteredChannelList extends FilterableFeed { * Applies given filter to the list. * @param filter - The filter to apply */ - async applyFilter(filter: string | ChipCloudChip) { + async applyFilter(filter: string | ChipCloudChip): Promise { const feed = await super.getFilteredFeed(filter); return new FilteredChannelList(this.actions, feed.page, true); } @@ -187,7 +188,7 @@ export class FilteredChannelList extends FilterableFeed { /** * Retrieves list continuation. */ - async getContinuation() { + async getContinuation(): Promise { const page = await super.getContinuationData(); // Keep the filters diff --git a/src/parser/youtube/Comments.ts b/src/parser/youtube/Comments.ts index 46c9a322b..4ab8b9f95 100644 --- a/src/parser/youtube/Comments.ts +++ b/src/parser/youtube/Comments.ts @@ -1,5 +1,6 @@ import Parser, { ParsedResponse } from '..'; -import Actions, { ActionsResponse } from '../../core/Actions'; +import type Actions from '../../core/Actions'; +import type { ApiResponse } from '../../core/Actions'; import { InnertubeError } from '../../utils/Utils'; import Button from '../classes/Button'; @@ -11,10 +12,10 @@ import ContinuationItem from '../classes/ContinuationItem'; class Comments { #page: ParsedResponse; #actions: Actions; - #continuation: ContinuationItem | undefined; + #continuation?: ContinuationItem; - header: CommentsHeader | undefined; - contents; + header?: CommentsHeader; + contents: CommentThread[]; constructor(actions: Actions, data: any, already_parsed = false) { this.#page = already_parsed ? data : Parser.parseResponse(data); @@ -25,7 +26,7 @@ class Comments { if (!contents) throw new InnertubeError('Comments page did not have any content.'); - this.header = contents[0].contents?.get({ type: 'CommentsHeader' })?.as(CommentsHeader); + this.header = contents[0].contents?.firstOfType(CommentsHeader); const threads: CommentThread[] = contents[1].contents?.filterType(CommentThread) || []; @@ -35,14 +36,14 @@ class Comments { return thread; }) as CommentThread[]; - this.#continuation = contents[1].contents?.get({ type: 'ContinuationItem' })?.as(ContinuationItem); + this.#continuation = contents[1].contents?.firstOfType(ContinuationItem); } /** * Creates a top-level comment. * @param text - Comment text. */ - async createComment(text: string): Promise { + async createComment(text: string): Promise { if (!this.header) throw new InnertubeError('Page header is missing. Cannot create comment.'); diff --git a/src/parser/youtube/History.ts b/src/parser/youtube/History.ts index 2774793a6..99acb7d69 100644 --- a/src/parser/youtube/History.ts +++ b/src/parser/youtube/History.ts @@ -1,18 +1,17 @@ -import Actions from '../../core/Actions'; - +import type Actions from '../../core/Actions'; import Feed from '../../core/Feed'; import ItemSection from '../classes/ItemSection'; import BrowseFeedActions from '../classes/BrowseFeedActions'; // TODO: make feed actions usable class History extends Feed { - sections; - feed_actions; + sections: ItemSection[]; + feed_actions: BrowseFeedActions; constructor(actions: Actions, data: any, already_parsed = false) { super(actions, data, already_parsed); - this.sections = this.memo.get('ItemSection') as ItemSection[]; - this.feed_actions = this.memo.get('BrowseFeedActions')?.[0]?.as(BrowseFeedActions) || ([] as BrowseFeedActions[]); + this.sections = this.memo.getType(ItemSection); + this.feed_actions = this.memo.getType(BrowseFeedActions)?.[0]; } /** diff --git a/src/parser/youtube/HomeFeed.ts b/src/parser/youtube/HomeFeed.ts index 4010bcad6..82a8b1acf 100644 --- a/src/parser/youtube/HomeFeed.ts +++ b/src/parser/youtube/HomeFeed.ts @@ -1,10 +1,10 @@ -import Actions from '../../core/Actions'; +import type Actions from '../../core/Actions'; import FilterableFeed from '../../core/FilterableFeed'; import ChipCloudChip from '../classes/ChipCloudChip'; import FeedTabbedHeader from '../classes/FeedTabbedHeader'; import RichGrid from '../classes/RichGrid'; -import { ReloadContinuationItemsCommand, AppendContinuationItemsAction } from '..'; +import type { AppendContinuationItemsAction, ReloadContinuationItemsCommand } from '..'; export default class HomeFeed extends FilterableFeed { contents: RichGrid | AppendContinuationItemsAction | ReloadContinuationItemsCommand; @@ -19,7 +19,7 @@ export default class HomeFeed extends FilterableFeed { } /** - * Applies given filter to the feed. + * Applies given filter to the feed. Use {@link filters} to get available filters. * @param filter - Filter to apply. */ async applyFilter(filter: string | ChipCloudChip): Promise { diff --git a/src/parser/youtube/ItemMenu.ts b/src/parser/youtube/ItemMenu.ts index 5e8c014cd..913c6a3d3 100644 --- a/src/parser/youtube/ItemMenu.ts +++ b/src/parser/youtube/ItemMenu.ts @@ -1,13 +1,12 @@ -import Actions from '../../core/Actions'; - +import Button from '../classes/Button'; import Menu from '../classes/menus/Menu'; import MenuServiceItem from '../classes/menus/MenuServiceItem'; import NavigationEndpoint from '../classes/NavigationEndpoint'; -import Button from '../classes/Button'; -import { ParsedResponse } from '..'; +import type Actions from '../../core/Actions'; +import type { ParsedResponse } from '..'; import { InnertubeError } from '../../utils/Utils'; -import { ObservedArray, YTNode } from '../helpers'; +import type { ObservedArray, YTNode } from '../helpers'; class ItemMenu { #page: ParsedResponse; diff --git a/src/parser/youtube/Library.ts b/src/parser/youtube/Library.ts index 8e7fae9b9..b1dc4b9e1 100644 --- a/src/parser/youtube/Library.ts +++ b/src/parser/youtube/Library.ts @@ -1,5 +1,6 @@ import Parser, { ParsedResponse } from '..'; -import Actions, { ApiResponse } from '../../core/Actions'; +import type Actions from '../../core/Actions'; +import type { ApiResponse } from '../../core/Actions'; import { InnertubeError } from '../../utils/Utils'; import Feed from '../../core/Feed'; @@ -13,10 +14,14 @@ import ProfileColumnStats from '../classes/ProfileColumnStats'; import ProfileColumnUserInfo from '../classes/ProfileColumnUserInfo'; class Library { - #actions; - #page; + #actions: Actions; + #page: ParsedResponse; + + profile: { + stats?: ProfileColumnStats; + user_info?: ProfileColumnUserInfo; + }; - profile; sections; constructor(response: ApiResponse, actions: Actions) { @@ -30,10 +35,10 @@ class Library { const shelves = this.#page.contents_memo.getType(Shelf); - this.sections = shelves.map((shelf: any) => ({ + this.sections = shelves.map((shelf: Shelf) => ({ type: shelf.icon_type, title: shelf.title, - contents: shelf.content?.item().items || [], + contents: shelf.content?.key('items').array() || [], getAll: () => this.#getAll(shelf) })); } @@ -42,7 +47,7 @@ class Library { if (!shelf.menu?.as(Menu).hasKey('top_level_buttons')) throw new InnertubeError(`The ${shelf.title.text} shelf doesn't have more items`); - const button = shelf.menu.as(Menu).top_level_buttons.get({ text: 'See all' }); + const button = shelf.menu.as(Menu).top_level_buttons.firstOfType(Button); if (!button) throw new InnertubeError('Did not find target button.'); diff --git a/src/parser/youtube/LiveChat.ts b/src/parser/youtube/LiveChat.ts index 9d737aed3..8969203e6 100644 --- a/src/parser/youtube/LiveChat.ts +++ b/src/parser/youtube/LiveChat.ts @@ -1,5 +1,5 @@ -import Parser, { LiveChatContinuation, ParsedResponse } from '../index'; import EventEmitter from '../../utils/EventEmitterLike'; +import Parser, { LiveChatContinuation, ParsedResponse } from '../index'; import VideoInfo from './VideoInfo'; import AddChatItemAction from '../classes/livechat/AddChatItemAction'; @@ -10,11 +10,11 @@ import ReplaceChatItemAction from '../classes/livechat/ReplaceChatItemAction'; import ReplayChatItemAction from '../classes/livechat/ReplayChatItemAction'; import ShowLiveChatActionPanelAction from '../classes/livechat/ShowLiveChatActionPanelAction'; -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 UpdateDescriptionAction from '../classes/livechat/UpdateDescriptionAction'; +import UpdateTitleAction from '../classes/livechat/UpdateTitleAction'; import UpdateToggleButtonTextAction from '../classes/livechat/UpdateToggleButtonTextAction'; +import UpdateViewershipAction from '../classes/livechat/UpdateViewershipAction'; import AddBannerToLiveChatCommand from '../classes/livechat/AddBannerToLiveChatCommand'; import RemoveBannerForLiveChatCommand from '../classes/livechat/RemoveBannerForLiveChatCommand'; @@ -22,16 +22,18 @@ import ShowLiveChatTooltipCommand from '../classes/livechat/ShowLiveChatTooltipC import Proto from '../../proto/index'; import { InnertubeError, uuidv4 } from '../../utils/Utils'; -import { ObservedArray, YTNode } from '../helpers'; +import type { ObservedArray, YTNode } from '../helpers'; -import LiveChatTextMessage from '../classes/livechat/items/LiveChatTextMessage'; -import LiveChatPaidMessage from '../classes/livechat/items/LiveChatPaidMessage'; -import LiveChatPaidSticker from '../classes/livechat/items/LiveChatPaidSticker'; +import Button from '../classes/Button'; import LiveChatAutoModMessage from '../classes/livechat/items/LiveChatAutoModMessage'; import LiveChatMembershipItem from '../classes/livechat/items/LiveChatMembershipItem'; +import LiveChatPaidMessage from '../classes/livechat/items/LiveChatPaidMessage'; +import LiveChatPaidSticker from '../classes/livechat/items/LiveChatPaidSticker'; +import LiveChatTextMessage from '../classes/livechat/items/LiveChatTextMessage'; import LiveChatViewerEngagementMessage from '../classes/livechat/items/LiveChatViewerEngagementMessage'; import ItemMenu from './ItemMenu'; -import Button from '../classes/Button'; + +import type Actions from '../../core/Actions'; export type ChatAction = AddChatItemAction | AddBannerToLiveChatCommand | AddLiveChatTickerItemAction | @@ -49,9 +51,9 @@ export interface LiveMetadata { } class LiveChat extends EventEmitter { - #actions; - #video_info; - #continuation; + #actions: Actions; + #video_info: VideoInfo; + #continuation?: string; #mcontinuation?: string; initial_info?: LiveChatContinuation; diff --git a/src/parser/youtube/NotificationsMenu.ts b/src/parser/youtube/NotificationsMenu.ts index 34fcfcdbc..98cbdee51 100644 --- a/src/parser/youtube/NotificationsMenu.ts +++ b/src/parser/youtube/NotificationsMenu.ts @@ -1,35 +1,42 @@ -import Parser from '..'; -import Actions, { ApiResponse } from '../../core/Actions'; +import Parser, { ParsedResponse } from '..'; import { InnertubeError } from '../../utils/Utils'; -import Notification from '../classes/Notification'; -import SimpleMenuHeader from '../classes/menus/SimpleMenuHeader'; import ContinuationItem from '../classes/ContinuationItem'; +import SimpleMenuHeader from '../classes/menus/SimpleMenuHeader'; +import Notification from '../classes/Notification'; + +import type Actions from '../../core/Actions'; +import type { ApiResponse } from '../../core/Actions'; class NotificationsMenu { - #page; - #actions; + #page: ParsedResponse; + #actions: Actions; - header; - contents; + header: SimpleMenuHeader; + contents: Notification[]; constructor(actions: Actions, response: ApiResponse) { this.#actions = actions; this.#page = Parser.parseResponse(response.data); - this.header = this.#page.actions_memo.get('SimpleMenuHeader')?.[0]?.as(SimpleMenuHeader) || null; - this.contents = this.#page.actions_memo.get('Notification') as Notification[]; + this.header = this.#page.actions_memo.getType(SimpleMenuHeader)?.[0]; + this.contents = this.#page.actions_memo.getType(Notification); } async getContinuation(): Promise { - const continuation = this.#page.actions_memo.get('ContinuationItem')?.[0].as(ContinuationItem); + const continuation = this.#page.actions_memo.getType(ContinuationItem)?.[0]; if (!continuation) throw new InnertubeError('Continuation not found'); const response = await continuation.endpoint.call(this.#actions, { parse: false }); + return new NotificationsMenu(this.#actions, response); } + + get page(): ParsedResponse { + return this.#page; + } } export default NotificationsMenu; \ No newline at end of file diff --git a/src/parser/youtube/Playlist.ts b/src/parser/youtube/Playlist.ts index 8c413764c..92aac56e9 100644 --- a/src/parser/youtube/Playlist.ts +++ b/src/parser/youtube/Playlist.ts @@ -1,4 +1,3 @@ -import Actions from '../../core/Actions'; import Feed from '../../core/Feed'; import Thumbnail from '../classes/misc/Thumbnail'; @@ -13,6 +12,8 @@ import PlaylistHeader from '../classes/PlaylistHeader'; import { InnertubeError } from '../../utils/Utils'; +import type Actions from '../../core/Actions'; + class Playlist extends Feed { info; menu; @@ -43,7 +44,7 @@ class Playlist extends Feed { } }; - this.menu = primary_info?.menu.item(); + this.menu = primary_info?.menu; this.endpoint = primary_info?.endpoint; } diff --git a/src/parser/youtube/Search.ts b/src/parser/youtube/Search.ts index ccdcabe76..130981cbd 100644 --- a/src/parser/youtube/Search.ts +++ b/src/parser/youtube/Search.ts @@ -1,20 +1,20 @@ -import Actions from '../../core/Actions'; -import { ObservedArray, YTNode } from '../helpers'; -import { InnertubeError } from '../../utils/Utils'; - import Feed from '../../core/Feed'; -import SectionList from '../classes/SectionList'; -import ItemSection from '../classes/ItemSection'; import HorizontalCardList from '../classes/HorizontalCardList'; +import ItemSection from '../classes/ItemSection'; import SearchRefinementCard from '../classes/SearchRefinementCard'; +import SectionList from '../classes/SectionList'; import UniversalWatchCard from '../classes/UniversalWatchCard'; +import type Actions from '../../core/Actions'; +import { InnertubeError } from '../../utils/Utils'; +import type { ObservedArray, YTNode } from '../helpers'; + export default class Search extends Feed { - results: ObservedArray | null | undefined; - refinements; - estimated_results; - watch_card; - refinement_cards; + results?: ObservedArray | null; + refinements: string[]; + estimated_results: number | null; + watch_card: UniversalWatchCard | null; + refinement_cards?: HorizontalCardList | null; constructor(actions: Actions, data: any, already_parsed = false) { super(actions, data, already_parsed); @@ -33,9 +33,9 @@ export default class Search extends Feed { } /** - * Applies given refinement card and returns a new {@link Search} object. + * Applies given refinement card and returns a new {@link Search} object. Use {@link refinement_card_queries} to get a list of available refinement cards. */ - async selectRefinementCard(card: SearchRefinementCard | string) { + async selectRefinementCard(card: SearchRefinementCard | string): Promise { let target_card: SearchRefinementCard | undefined; if (typeof card === 'string') { @@ -57,8 +57,8 @@ export default class Search extends Feed { /** * Returns a list of refinement card queries. */ - get refinement_card_queries() { - return this.refinement_cards?.cards.as(SearchRefinementCard).map((card) => card.query); + get refinement_card_queries(): string[] { + return this.refinement_cards?.cards.as(SearchRefinementCard).map((card) => card.query) || []; } /** diff --git a/src/parser/youtube/Settings.ts b/src/parser/youtube/Settings.ts index 61954afbe..508dc48bf 100644 --- a/src/parser/youtube/Settings.ts +++ b/src/parser/youtube/Settings.ts @@ -1,20 +1,23 @@ -import Parser from '..'; -import Actions, { ApiResponse } from '../../core/Actions'; +import Parser, { ParsedResponse } from '..'; import { InnertubeError } from '../../utils/Utils'; +import ItemSection from '../classes/ItemSection'; +import SectionList from '../classes/SectionList'; import Tab from '../classes/Tab'; import TwoColumnBrowseResults from '../classes/TwoColumnBrowseResults'; -import SectionList from '../classes/SectionList'; -import ItemSection from '../classes/ItemSection'; +import CompactLink from '../classes/CompactLink'; import PageIntroduction from '../classes/PageIntroduction'; import SettingsOptions from '../classes/SettingsOptions'; -import SettingsSwitch from '../classes/SettingsSwitch'; import SettingsSidebar from '../classes/SettingsSidebar'; +import SettingsSwitch from '../classes/SettingsSwitch'; + +import type Actions from '../../core/Actions'; +import type { ApiResponse } from '../../core/Actions'; class Settings { - #page; - #actions; + #page: ParsedResponse; + #actions: Actions; sidebar: SettingsSidebar | null | undefined; introduction: PageIntroduction | null | undefined; @@ -33,7 +36,7 @@ class Settings { const contents = tab.content?.as(SectionList).contents.as(ItemSection); - this.introduction = contents?.shift()?.contents?.get({ type: 'PageIntroduction' })?.as(PageIntroduction); + this.introduction = contents?.shift()?.contents?.firstOfType(PageIntroduction); this.sections = contents?.map((el: ItemSection) => ({ title: el.header?.title.toString() || null, @@ -44,14 +47,21 @@ class Settings { /** * Selects an item from the sidebar menu. Use {@link sidebar_items} to see available items. */ - async selectSidebarItem(name: string) { + async selectSidebarItem(target_item: string | CompactLink): Promise { if (!this.sidebar) throw new InnertubeError('Sidebar not available'); - const item = this.sidebar.items.get({ title: name }); + let item: CompactLink | undefined; - if (!item) - throw new InnertubeError(`Item "${name}" not found`, { available_items: this.sidebar_items }); + if (typeof target_item === 'string') { + item = this.sidebar.items.get({ title: target_item }); + if (!item) + throw new InnertubeError(`Item "${target_item}" not found`, { available_items: this.sidebar_items }); + } else if (target_item?.is(CompactLink)) { + item = target_item; + } else { + throw new InnertubeError('Invalid item', { target_item }); + } const response = await item.endpoint.call(this.#actions, { parse: false }); @@ -61,7 +71,7 @@ class Settings { /** * Finds a setting by name and returns it. Use {@link setting_options} to see available options. */ - getSettingOption(name: string) { + getSettingOption(name: string): SettingsSwitch { if (!this.sections) throw new InnertubeError('Sections not available'); @@ -113,6 +123,10 @@ class Settings { return this.sidebar.items.map((item) => item.title.toString()); } + + get page(): ParsedResponse { + return this.#page; + } } export default Settings; \ No newline at end of file diff --git a/src/parser/youtube/TimeWatched.ts b/src/parser/youtube/TimeWatched.ts index 75543d573..a3ffa43e8 100644 --- a/src/parser/youtube/TimeWatched.ts +++ b/src/parser/youtube/TimeWatched.ts @@ -1,15 +1,15 @@ import Parser, { ParsedResponse } from '..'; -import { ApiResponse } from '../../core/Actions'; - import ItemSection from '../classes/ItemSection'; -import SingleColumnBrowseResults from '../classes/SingleColumnBrowseResults'; import SectionList from '../classes/SectionList'; +import SingleColumnBrowseResults from '../classes/SingleColumnBrowseResults'; import { InnertubeError } from '../../utils/Utils'; +import type { ApiResponse } from '../../core/Actions'; +import type { ObservedArray } from '../helpers'; class TimeWatched { - #page; - contents; + #page: ParsedResponse; + contents?: ObservedArray; constructor(response: ApiResponse) { this.#page = Parser.parseResponse(response.data); diff --git a/src/parser/youtube/VideoInfo.ts b/src/parser/youtube/VideoInfo.ts index e5c4f8ea3..afc604d6d 100644 --- a/src/parser/youtube/VideoInfo.ts +++ b/src/parser/youtube/VideoInfo.ts @@ -1,34 +1,45 @@ -import Parser, { ParsedResponse } from '../index'; import Constants from '../../utils/Constants'; -import Actions, { ApiResponse } from '../../core/Actions'; -import Player from '../../core/Player'; +import Parser, { ParsedResponse } from '../index'; import TwoColumnWatchNextResults from '../classes/TwoColumnWatchNextResults'; import VideoPrimaryInfo from '../classes/VideoPrimaryInfo'; import VideoSecondaryInfo from '../classes/VideoSecondaryInfo'; -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 ChipCloudChip from '../classes/ChipCloudChip'; import CommentsEntryPointHeader from '../classes/comments/CommentsEntryPointHeader'; -import SegmentedLikeDislikeButton from '../classes/SegmentedLikeDislikeButton'; import ContinuationItem from '../classes/ContinuationItem'; -import PlayerMicroformat from '../classes/PlayerMicroformat'; -import MicroformatData from '../classes/MicroformatData'; - +import ItemSection from '../classes/ItemSection'; import LiveChat from '../classes/LiveChat'; +import MicroformatData from '../classes/MicroformatData'; +import PlayerMicroformat from '../classes/PlayerMicroformat'; +import PlayerOverlay from '../classes/PlayerOverlay'; +import SegmentedLikeDislikeButton from '../classes/SegmentedLikeDislikeButton'; +import ToggleButton from '../classes/ToggleButton'; import LiveChatWrap from './LiveChat'; +import type CardCollection from '../classes/CardCollection'; +import type Endscreen from '../classes/Endscreen'; +import type Format from '../classes/misc/Format'; +import type PlayerAnnotationsExpanded from '../classes/PlayerAnnotationsExpanded'; +import type PlayerCaptionsTracklist from '../classes/PlayerCaptionsTracklist'; +import type PlayerLiveStoryboardSpec from '../classes/PlayerLiveStoryboardSpec'; +import type PlayerStoryboardSpec from '../classes/PlayerStoryboardSpec'; + import { DOMParser } from 'linkedom'; -import type { XMLDocument } from 'linkedom/types/xml/document'; import type { Element } from 'linkedom/types/interface/element'; -import { getStringBetweenStrings, InnertubeError, streamToIterable } from '../../utils/Utils'; import type { Node } from 'linkedom/types/interface/node'; +import type { XMLDocument } from 'linkedom/types/xml/document'; + +import type Player from '../../core/Player'; +import type Actions from '../../core/Actions'; +import type { ApiResponse } from '../../core/Actions'; +import type { ObservedArray, YTNode } from '../helpers'; + +import { getStringBetweenStrings, InnertubeError, streamToIterable } from '../../utils/Utils'; export type URLTransformer = (url: URL) => URL; @@ -46,9 +57,9 @@ export interface FormatOptions { */ format?: string; /** - * InnerTube client, can be ANDROID, WEB, YTMUSIC, YTMUSIC_ANDROID or TV_EMBEDDED + * InnerTube client, can be ANDROID, WEB, YTMUSIC, YTMUSIC_ANDROID, YTSTUDIO_ANDROID or TV_EMBEDDED */ - client?: 'ANDROID' | 'WEB' | 'YTMUSIC' | 'YTMUSIC_ANDROID' | 'TV_EMBEDDED' + client?: 'WEB' | 'ANDROID' | 'YTMUSIC_ANDROID' | 'YTMUSIC' | 'YTSTUDIO_ANDROID' | 'TV_EMBEDDED'; } export interface DownloadOptions extends FormatOptions { @@ -63,34 +74,37 @@ export interface DownloadOptions extends FormatOptions { class VideoInfo { #page: [ParsedResponse, ParsedResponse?]; - #actions; - #player; - #cpn; - #watch_next_continuation; + + #actions: Actions; + #player?: Player; + #cpn?: string; + #watch_next_continuation?: ContinuationItem; basic_info; streaming_data; playability_status; - annotations; - storyboards; - endscreen; - captions; - cards; + annotations: ObservedArray; + storyboards: PlayerStoryboardSpec | PlayerLiveStoryboardSpec | null; + endscreen: Endscreen | null; + captions: PlayerCaptionsTracklist | null; + cards: CardCollection | null; #playback_tracking; - primary_info; - secondary_info; - merchandise; - related_chip_cloud; - watch_next_feed; - player_overlays; - comments_entry_point_header; - livechat; + primary_info?: VideoPrimaryInfo | null; + secondary_info?: VideoSecondaryInfo | null; + merchandise?: MerchandiseShelf | null; + related_chip_cloud?: ChipCloud | null; + watch_next_feed?: ObservedArray | null; + player_overlays?: PlayerOverlay | null; + comments_entry_point_header?: CommentsEntryPointHeader | null; + livechat?: LiveChat | null; /** * @param data - API response. - * @param cpn - Client Playback Nonce + * @param actions - Actions instance. + * @param player - Player instance. + * @param cpn - Client Playback Nonce. */ constructor(data: [ApiResponse, ApiResponse?], actions: Actions, player?: Player, cpn?: string) { this.#actions = actions; @@ -142,12 +156,12 @@ class VideoInfo { 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.primary_info = results.firstOfType(VideoPrimaryInfo); + this.secondary_info = results.firstOfType(VideoSecondaryInfo); + this.merchandise = results.firstOfType(MerchandiseShelf); + this.related_chip_cloud = secondary_results.firstOfType(RelatedChipCloud)?.content.item().as(ChipCloud); - this.watch_next_feed = secondary_results.get({ type: 'ItemSection' })?.as(ItemSection)?.contents; + this.watch_next_feed = secondary_results.firstOfType(ItemSection)?.contents; if (this.watch_next_feed && Array.isArray(this.watch_next_feed)) this.#watch_next_continuation = this.watch_next_feed.pop()?.as(ContinuationItem); @@ -162,22 +176,34 @@ class VideoInfo { 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.comments_entry_point_header = comments_entry_point?.contents?.firstOfType(CommentsEntryPointHeader); this.livechat = next?.contents_memo.getType(LiveChat)?.[0]; } } /** - * Applies given filter to the watch next feed. + * Applies given filter to the watch next feed. Use {@link filters} to get available filters. + * @param target_filter - Filter to apply. */ - async selectFilter(name: string) { - if (!this.filters.includes(name)) - throw new InnertubeError('Invalid filter', { available_filters: this.filters }); + async selectFilter(target_filter: string | ChipCloudChip | undefined): Promise { + let cloud_chip: ChipCloudChip; + + if (typeof target_filter === 'string') { + const filter = this.related_chip_cloud?.chips?.get({ text: target_filter }); + + if (!filter) + 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; + cloud_chip = filter; + } else if (target_filter?.is(ChipCloudChip)) { + cloud_chip = target_filter; + } else { + throw new InnertubeError('Invalid cloud chip', target_filter); + } + + if (cloud_chip.is_selected) return this; - const response = await filter?.endpoint?.call(this.#actions, { parse: true }); + const response = await cloud_chip.endpoint?.call(this.#actions, { parse: true }); const data = response?.on_response_received_endpoints?.get({ target_id: 'watch-next-feed' }); this.watch_next_feed = data?.contents; @@ -186,9 +212,9 @@ class VideoInfo { } /** - * Adds the video to the watch history. + * Adds video to the watch history. */ - async addToWatchHistory() { + async addToWatchHistory(): Promise { if (!this.#playback_tracking) throw new InnertubeError('Playback tracking not available'); @@ -212,7 +238,7 @@ class VideoInfo { /** * Retrieves watch next feed continuation. */ - async getWatchNextContinuation() { + async getWatchNextContinuation(): Promise { const response = await this.#watch_next_continuation?.endpoint.call(this.#actions, { parse: true }); const data = response?.on_response_received_endpoints?.get({ type: 'appendContinuationItemsAction' }); @@ -228,7 +254,7 @@ class VideoInfo { /** * Likes the video. */ - async like() { + async like(): Promise { const segmented_like_dislike_button = this.primary_info?.menu?.top_level_buttons.firstOfType(SegmentedLikeDislikeButton); const button = segmented_like_dislike_button?.like_button?.as(ToggleButton); @@ -246,7 +272,7 @@ class VideoInfo { /** * Dislikes the video. */ - async dislike() { + async dislike(): Promise { const segmented_like_dislike_button = this.primary_info?.menu?.top_level_buttons.firstOfType(SegmentedLikeDislikeButton); const button = segmented_like_dislike_button?.dislike_button?.as(ToggleButton); @@ -264,7 +290,7 @@ class VideoInfo { /** * Removes like/dislike. */ - async removeLike() { + async removeRating(): Promise { let button; const segmented_like_dislike_button = this.primary_info?.menu?.top_level_buttons.firstOfType(SegmentedLikeDislikeButton); @@ -288,25 +314,37 @@ class VideoInfo { /** * Retrieves Live Chat if available. */ - getLiveChat() { + getLiveChat(): LiveChatWrap { if (!this.livechat) throw new InnertubeError('Live Chat is not available', { video_id: this.basic_info.id }); return new LiveChatWrap(this); } - get filters() { + /** + * Watch next feed filters. + */ + get filters(): string[] { return this.related_chip_cloud?.chips?.map((chip) => chip.text.toString()) || []; } - get actions() { + /** + * Actions instance. + */ + get actions(): Actions { return this.#actions; } - get cpn() { + /** + * Content Playback Nonce. + */ + get cpn(): string | undefined { return this.#cpn; } - get page() { + /** + * Original parsed InnerTube response. + */ + get page(): [ ParsedResponse, ParsedResponse? ] { return this.#page; } @@ -349,7 +387,11 @@ class VideoInfo { return []; } - chooseFormat(options: FormatOptions) { + /** + * Selects the format that best matches the given options. + * @param options - Options + */ + chooseFormat(options: FormatOptions): Format { if (!this.streaming_data) throw new InnertubeError('Streaming data not available', { video_id: this.basic_info.id }); @@ -420,7 +462,12 @@ class VideoInfo { return el; } - toDash(url_transformer: URLTransformer = (url) => url) { + /** + * Generates a DASH manifest from the streaming data. + * @param url_transformer - Function to transform the URLs. + * @returns DASH manifest + */ + toDash(url_transformer: URLTransformer = (url) => url): string { if (!this.streaming_data) throw new InnertubeError('Streaming data not available', { video_id: this.basic_info.id }); @@ -547,9 +594,10 @@ class VideoInfo { } /** - * @param options - download options. + * Downloads the video. + * @param options - Download options. */ - async download(options: DownloadOptions = {}) { + async download(options: DownloadOptions = {}): Promise> { if (this.playability_status?.status === 'UNPLAYABLE') throw new InnertubeError('Video is unplayable', { video: this, error_type: 'UNPLAYABLE' }); if (this.playability_status?.status === 'LOGIN_REQUIRED') @@ -600,7 +648,7 @@ class VideoInfo { const readable_stream = new ReadableStream({ // eslint-disable-next-line @typescript-eslint/no-empty-function - start() {}, + start() { }, pull: async (controller) => { if (must_end) { controller.close(); diff --git a/src/parser/ytmusic/Album.ts b/src/parser/ytmusic/Album.ts index c2c76f90e..a9c307d65 100644 --- a/src/parser/ytmusic/Album.ts +++ b/src/parser/ytmusic/Album.ts @@ -1,18 +1,22 @@ +import type Actions from '../../core/Actions'; +import type { ApiResponse } from '../../core/Actions'; +import type { ObservedArray } from '../helpers'; import Parser, { ParsedResponse } from '../index'; -import Actions, { ApiResponse } from '../../core/Actions'; -import MusicDetailHeader from '../classes/MusicDetailHeader'; import MicroformatData from '../classes/MicroformatData'; import MusicCarouselShelf from '../classes/MusicCarouselShelf'; +import MusicDetailHeader from '../classes/MusicDetailHeader'; import MusicShelf from '../classes/MusicShelf'; +import type MusicResponsiveListItem from '../classes/MusicResponsiveListItem'; + class Album { - #page; - #actions; + #page: ParsedResponse; + #actions: Actions; - header; - contents; - sections; + header?: MusicDetailHeader | null; + contents: ObservedArray; + sections: ObservedArray; url: string | null; @@ -23,8 +27,8 @@ class Album { this.header = this.#page.header?.item().as(MusicDetailHeader); this.url = this.#page.microformat?.as(MicroformatData).url_canonical || null; - this.contents = this.#page.contents_memo.get('MusicShelf')?.[0].as(MusicShelf).contents; - this.sections = this.#page.contents_memo.get('MusicCarouselShelf') as MusicCarouselShelf[] || ([] as MusicCarouselShelf[]); + this.contents = this.#page.contents_memo.getType(MusicShelf)?.[0].contents; + this.sections = this.#page.contents_memo.getType(MusicCarouselShelf) || []; } get page(): ParsedResponse { diff --git a/src/parser/ytmusic/Artist.ts b/src/parser/ytmusic/Artist.ts index 49c1c97f2..e013df125 100644 --- a/src/parser/ytmusic/Artist.ts +++ b/src/parser/ytmusic/Artist.ts @@ -1,5 +1,6 @@ import Parser, { ParsedResponse } from '../index'; -import Actions, { ApiResponse } from '../../core/Actions'; +import type Actions from '../../core/Actions'; +import type { ApiResponse } from '../../core/Actions'; import { InnertubeError } from '../../utils/Utils'; import MusicShelf from '../classes/MusicShelf'; @@ -10,11 +11,11 @@ import MusicVisualHeader from '../classes/MusicVisualHeader'; import MusicHeader from '../classes/MusicHeader'; class Artist { - #page; - #actions; + #page: ParsedResponse; + #actions: Actions; - header; - sections; + header?: MusicImmersiveHeader | MusicVisualHeader | MusicHeader; + sections: (MusicCarouselShelf | MusicShelf)[]; constructor(response: ApiResponse | ParsedResponse, actions: Actions) { this.#page = Parser.parseResponse((response as ApiResponse).data); @@ -22,8 +23,8 @@ class Artist { this.header = this.page.header?.item().as(MusicImmersiveHeader, MusicVisualHeader, MusicHeader); - const music_shelf = this.#page.contents_memo.get('MusicShelf') as MusicShelf[] || []; - const music_carousel_shelf = this.#page.contents_memo.get('MusicCarouselShelf') as MusicCarouselShelf[] || []; + const music_shelf = this.#page.contents_memo.getType(MusicShelf) || []; + const music_carousel_shelf = this.#page.contents_memo.getType(MusicCarouselShelf) || []; this.sections = [ ...music_shelf, ...music_carousel_shelf ]; } @@ -43,7 +44,7 @@ class Artist { throw new InnertubeError('Target shelf (Songs) did not have an endpoint.'); const page = await shelf.endpoint.call(this.#actions, { client: 'YTMUSIC', parse: true }); - const contents = page.contents_memo.get('MusicPlaylistShelf')?.[0]?.as(MusicPlaylistShelf) || null; + const contents = page.contents_memo.getType(MusicPlaylistShelf)?.[0] || null; return contents; } diff --git a/src/parser/ytmusic/Explore.ts b/src/parser/ytmusic/Explore.ts index 29a8a3900..a6e672789 100644 --- a/src/parser/ytmusic/Explore.ts +++ b/src/parser/ytmusic/Explore.ts @@ -1,19 +1,20 @@ import Parser, { ParsedResponse } from '..'; -import { InnertubeError } from '../../utils/Utils'; -import { ApiResponse } from '../../core/Actions'; - import Grid from '../classes/Grid'; +import MusicCarouselShelf from '../classes/MusicCarouselShelf'; +import MusicNavigationButton from '../classes/MusicNavigationButton'; import SectionList from '../classes/SectionList'; import SingleColumnBrowseResults from '../classes/SingleColumnBrowseResults'; -import MusicNavigationButton from '../classes/MusicNavigationButton'; -import MusicCarouselShelf from '../classes/MusicCarouselShelf'; + +import type { ApiResponse } from '../../core/Actions'; +import type { ObservedArray } from '../helpers'; +import { InnertubeError } from '../../utils/Utils'; class Explore { - #page; + #page: ParsedResponse; - top_buttons; - sections; + top_buttons: MusicNavigationButton[]; + sections: ObservedArray; constructor(response: ApiResponse) { this.#page = Parser.parseResponse(response.data); @@ -28,8 +29,8 @@ class Explore { if (!section_list) throw new InnertubeError('Target tab did not have any content.'); - this.top_buttons = section_list.contents.firstOfType(Grid)?.items.as(MusicNavigationButton) || ([] as MusicNavigationButton[]); - this.sections = section_list.contents.getAll({ type: 'MusicCarouselShelf' }) as MusicCarouselShelf[]; + this.top_buttons = section_list.contents.firstOfType(Grid)?.items.as(MusicNavigationButton) || []; + this.sections = section_list.contents.filterType(MusicCarouselShelf); } get page(): ParsedResponse { diff --git a/src/parser/ytmusic/HomeFeed.ts b/src/parser/ytmusic/HomeFeed.ts index 1fb396932..660f18fc5 100644 --- a/src/parser/ytmusic/HomeFeed.ts +++ b/src/parser/ytmusic/HomeFeed.ts @@ -1,17 +1,20 @@ -import Parser, { ParsedResponse, SectionListContinuation } from '../index'; -import Actions, { ApiResponse } from '../../core/Actions'; +import type Actions from '../../core/Actions'; +import type { ApiResponse } from '../../core/Actions'; +import type { ObservedArray } from '../helpers'; import { InnertubeError } from '../../utils/Utils'; +import Parser, { ParsedResponse, SectionListContinuation } from '../index'; + +import MusicCarouselShelf from '../classes/MusicCarouselShelf'; import SectionList from '../classes/SectionList'; import SingleColumnBrowseResults from '../classes/SingleColumnBrowseResults'; -import MusicCarouselShelf from '../classes/MusicCarouselShelf'; class HomeFeed { - #page; - #actions; - #continuation; + #page: ParsedResponse; + #actions: Actions; + #continuation?: string; - sections; + sections?: ObservedArray; constructor(response: ApiResponse | ParsedResponse, actions: Actions) { this.#actions = actions; @@ -55,7 +58,7 @@ class HomeFeed { return !!this.#continuation; } - get page() { + get page(): ParsedResponse { return this.#page; } } diff --git a/src/parser/ytmusic/Library.ts b/src/parser/ytmusic/Library.ts index 3350bd639..15e63876f 100644 --- a/src/parser/ytmusic/Library.ts +++ b/src/parser/ytmusic/Library.ts @@ -1,5 +1,6 @@ import Parser, { ParsedResponse } from '..'; -import Actions, { ApiResponse } from '../../core/Actions'; +import type Actions from '../../core/Actions'; +import type { ApiResponse } from '../../core/Actions'; import Grid from '../classes/Grid'; import MusicShelf from '../classes/MusicShelf'; @@ -14,14 +15,15 @@ import MusicSortFilterButton from '../classes/MusicSortFilterButton'; import MusicMenuItemDivider from '../classes/menus/MusicMenuItemDivider'; import { InnertubeError } from '../../utils/Utils'; +import type { ObservedArray } from '../helpers'; class Library { - #page; - #actions; - #continuation; + #page: ParsedResponse; + #actions: Actions; + #continuation?: string | null; - header; - contents; + header?: MusicSideAlignedItem; + contents?: ObservedArray; constructor(response: ApiResponse, actions: Actions) { this.#page = Parser.parseResponse(response.data); @@ -38,7 +40,7 @@ class Library { /** * Applies given sort filter to the library items. */ - async applySortFilter(sort_by: string | MusicMultiSelectMenuItem) { + async applySortFilter(sort_by: string | MusicMultiSelectMenuItem): Promise { let target_item: MusicMultiSelectMenuItem | undefined; if (typeof sort_by === 'string') { diff --git a/src/parser/ytmusic/Playlist.ts b/src/parser/ytmusic/Playlist.ts index 1f7f70a8a..5192ac059 100644 --- a/src/parser/ytmusic/Playlist.ts +++ b/src/parser/ytmusic/Playlist.ts @@ -1,25 +1,27 @@ import Parser, { MusicPlaylistShelfContinuation, ParsedResponse, SectionListContinuation } from '../index'; -import Actions, { ApiResponse } from '../../core/Actions'; import MusicCarouselShelf from '../classes/MusicCarouselShelf'; -import MusicPlaylistShelf from '../classes/MusicPlaylistShelf'; -import MusicEditablePlaylistDetailHeader from '../classes/MusicEditablePlaylistDetailHeader'; import MusicDetailHeader from '../classes/MusicDetailHeader'; +import MusicEditablePlaylistDetailHeader from '../classes/MusicEditablePlaylistDetailHeader'; +import MusicPlaylistShelf from '../classes/MusicPlaylistShelf'; +import MusicResponsiveListItem from '../classes/MusicResponsiveListItem'; import MusicShelf from '../classes/MusicShelf'; - import SectionList from '../classes/SectionList'; import { InnertubeError } from '../../utils/Utils'; +import type { ObservedArray, YTNode } from '../helpers'; +import type Actions from '../../core/Actions'; +import type { ApiResponse } from '../../core/Actions'; class Playlist { - #page; - #actions; - #continuation; + #page: ParsedResponse; + #actions: Actions; + #continuation: string | null; #last_fetched_suggestions: any; #suggestions_continuation: any; - header; - items; + header?: MusicDetailHeader | null; + items: ObservedArray | null; constructor(response: ApiResponse, actions: Actions) { this.#actions = actions; @@ -46,7 +48,7 @@ class Playlist { /** * Retrieves playlist items continuation. */ - async getContinuation() { + async getContinuation(): Promise { if (!this.#continuation) throw new InnertubeError('Continuation not found.'); @@ -62,7 +64,7 @@ class Playlist { * Retrieves related playlists */ async getRelated(): Promise { - let section_continuation = this.#page.contents_memo.get('SectionList')?.[0].as(SectionList).continuation; + let section_continuation = this.#page.contents_memo.getType(SectionList)?.[0].continuation; while (section_continuation) { const data = await this.#actions.execute('/browse', { @@ -98,7 +100,7 @@ class Playlist { return fetch_result?.items || this.#last_fetched_suggestions; } - async #fetchSuggestions() { + async #fetchSuggestions(): Promise<{ items: never[] | ObservedArray, continuation: string | null }> { const continuation = this.#suggestions_continuation || this.#page.contents_memo.get('SectionList')?.[0].as(SectionList).continuation; if (continuation) { @@ -129,7 +131,7 @@ class Playlist { return this.#page; } - get has_continuation() { + get has_continuation(): boolean { return !!this.#continuation; } } diff --git a/src/parser/ytmusic/Recap.ts b/src/parser/ytmusic/Recap.ts index cc912a9f9..55305a592 100644 --- a/src/parser/ytmusic/Recap.ts +++ b/src/parser/ytmusic/Recap.ts @@ -1,26 +1,29 @@ import Parser, { ParsedResponse } from '../index'; -import Actions, { ApiResponse } from '../../core/Actions'; -import Playlist from './Playlist'; -import MusicHeader from '../classes/MusicHeader'; +import type Actions from '../../core/Actions'; +import type { ApiResponse } from '../../core/Actions'; + +import HighlightsCarousel from '../classes/HighlightsCarousel'; import MusicCarouselShelf from '../classes/MusicCarouselShelf'; import MusicElementHeader from '../classes/MusicElementHeader'; -import HighlightsCarousel from '../classes/HighlightsCarousel'; +import MusicHeader from '../classes/MusicHeader'; import SingleColumnBrowseResults from '../classes/SingleColumnBrowseResults'; +import Playlist from './Playlist'; -import Tab from '../classes/Tab'; import ItemSection from '../classes/ItemSection'; -import SectionList from '../classes/SectionList'; import Message from '../classes/Message'; +import SectionList from '../classes/SectionList'; +import Tab from '../classes/Tab'; import { InnertubeError } from '../../utils/Utils'; +import type { ObservedArray } from '../helpers'; class Recap { - #page; - #actions; + #page: ParsedResponse; + #actions: Actions; - header; - sections; + header?: HighlightsCarousel | MusicHeader; + sections?: ObservedArray; constructor(response: ApiResponse, actions: Actions) { this.#page = Parser.parseResponse(response.data); @@ -43,7 +46,7 @@ class Recap { /** * Retrieves recap playlist. */ - async getPlaylist() { + async getPlaylist(): Promise { if (!this.header) throw new InnertubeError('Header not found'); diff --git a/src/parser/ytmusic/Search.ts b/src/parser/ytmusic/Search.ts index 746aa2cea..e845fb0e8 100644 --- a/src/parser/ytmusic/Search.ts +++ b/src/parser/ytmusic/Search.ts @@ -1,34 +1,37 @@ -import Parser, { ParsedResponse } from '../index'; -import Actions, { ApiResponse } from '../../core/Actions'; +import type Actions from '../../core/Actions'; +import type { ApiResponse } from '../../core/Actions'; +import Parser, { ParsedResponse } from '../index'; import { InnertubeError } from '../../utils/Utils'; import SectionList from '../classes/SectionList'; import TabbedSearchResults from '../classes/TabbedSearchResults'; import DidYouMean from '../classes/DidYouMean'; -import ShowingResultsFor from '../classes/ShowingResultsFor'; -import MusicShelf from '../classes/MusicShelf'; import MusicResponsiveListItem from '../classes/MusicResponsiveListItem'; +import MusicShelf from '../classes/MusicShelf'; +import ShowingResultsFor from '../classes/ShowingResultsFor'; import ChipCloud from '../classes/ChipCloud'; import ChipCloudChip from '../classes/ChipCloudChip'; import ItemSection from '../classes/ItemSection'; import Message from '../classes/Message'; +import type { ObservedArray } from '../helpers'; + class Search { - #page; - #actions; - #continuation; + #page: ParsedResponse; + #actions: Actions; + #continuation?: string | null; - header; + header?: ChipCloud | null; did_you_mean: DidYouMean | null; showing_results_for: ShowingResultsFor | null; message: Message | null; - results; - sections; + results?: ObservedArray; + sections?: ObservedArray; constructor(response: ApiResponse | ParsedResponse, actions: Actions, args: { is_continuation?: boolean, is_filtered?: boolean } = {}) { this.#actions = actions; @@ -82,7 +85,7 @@ class Search { /** * Retrieves continuation, only works for individual sections or filtered results. */ - async getContinuation() { + async getContinuation(): Promise { if (!this.#continuation) throw new InnertubeError('Continuation not found.'); @@ -102,15 +105,22 @@ class Search { /** * Applies given filter to the search. */ - async selectFilter(name: string): Promise { - if (!this.filters?.includes(name)) + async selectFilter(target_filter: string | ChipCloudChip): Promise { + let cloud_chip: ChipCloudChip | undefined; + + if (typeof target_filter === 'string') { + cloud_chip = this.header?.chips?.as(ChipCloudChip).get({ text: target_filter }); + if (!cloud_chip) + throw new InnertubeError('Could not find filter with given name.', { available_filters: this.filters }); + } else if (target_filter?.is(ChipCloudChip)) { + cloud_chip = target_filter; + } else { throw new InnertubeError('Invalid filter', { available_filters: this.filters }); + } - const filter = this.header?.chips?.as(ChipCloudChip).get({ text: name }); - - if (filter?.is_selected) return this; + if (cloud_chip?.is_selected) return this; - const response = await filter?.endpoint?.call(this.#actions, { parse: true, client: 'YTMUSIC' }); + const response = await cloud_chip?.endpoint?.call(this.#actions, { parse: true, client: 'YTMUSIC' }); if (!response) throw new InnertubeError('Endpoint did not return any data'); @@ -122,8 +132,8 @@ class Search { return !!this.#continuation; } - get filters(): string[] | null { - return this.header?.chips?.as(ChipCloudChip).map((chip) => chip.text) || null; + get filters(): string[] { + return this.header?.chips?.as(ChipCloudChip).map((chip) => chip.text) || []; } get songs(): MusicShelf | undefined { diff --git a/src/parser/ytmusic/TrackInfo.ts b/src/parser/ytmusic/TrackInfo.ts index 725ae7ccc..06bd70b62 100644 --- a/src/parser/ytmusic/TrackInfo.ts +++ b/src/parser/ytmusic/TrackInfo.ts @@ -1,38 +1,46 @@ import Parser, { ParsedResponse } from '..'; -import Actions, { ApiResponse } from '../../core/Actions'; +import type Actions from '../../core/Actions'; +import type { ApiResponse } from '../../core/Actions'; + import Constants from '../../utils/Constants'; import { InnertubeError } from '../../utils/Utils'; -import Tab from '../classes/Tab'; -import WatchNextTabbedResults from '../classes/WatchNextTabbedResults'; +import AutomixPreviewVideo from '../classes/AutomixPreviewVideo'; +import Endscreen from '../classes/Endscreen'; +import Message from '../classes/Message'; import MicroformatData from '../classes/MicroformatData'; +import MusicCarouselShelf from '../classes/MusicCarouselShelf'; +import MusicDescriptionShelf from '../classes/MusicDescriptionShelf'; +import MusicQueue from '../classes/MusicQueue'; import PlayerOverlay from '../classes/PlayerOverlay'; import PlaylistPanel from '../classes/PlaylistPanel'; +import RichGrid from '../classes/RichGrid'; import SectionList from '../classes/SectionList'; -import MusicQueue from '../classes/MusicQueue'; -import MusicCarouselShelf from '../classes/MusicCarouselShelf'; -import MusicDescriptionShelf from '../classes/MusicDescriptionShelf'; -import AutomixPreviewVideo from '../classes/AutomixPreviewVideo'; -import Message from '../classes/Message'; +import Tab from '../classes/Tab'; +import WatchNextTabbedResults from '../classes/WatchNextTabbedResults'; + +import type NavigationEndpoint from '../classes/NavigationEndpoint'; +import type PlayerLiveStoryboardSpec from '../classes/PlayerLiveStoryboardSpec'; +import type PlayerStoryboardSpec from '../classes/PlayerStoryboardSpec'; -import { ObservedArray } from '../helpers'; +import type { ObservedArray, YTNode } from '../helpers'; class TrackInfo { #page: [ ParsedResponse, ParsedResponse? ]; #actions: Actions; - #cpn; + #cpn: string; basic_info; streaming_data; playability_status; - storyboards; - endscreen; + storyboards: PlayerStoryboardSpec | PlayerLiveStoryboardSpec | null; + endscreen: Endscreen | null; #playback_tracking; - tabs; - current_video_endpoint; - player_overlays; + tabs?: ObservedArray; + current_video_endpoint?: NavigationEndpoint | null; + player_overlays?: PlayerOverlay; constructor(data: [ApiResponse, ApiResponse?], actions: Actions, cpn: string) { this.#actions = actions; @@ -81,7 +89,7 @@ class TrackInfo { /** * Retrieves contents of the given tab. */ - async getTab(title_or_page_type: string) { + async getTab(title_or_page_type: string): Promise | SectionList | MusicQueue | RichGrid | Message> { if (!this.tabs) throw new InnertubeError('Could not find any tab'); @@ -155,7 +163,7 @@ class TrackInfo { /** * Adds the song to the watch history. */ - async addToWatchHistory() { + async addToWatchHistory(): Promise { if (!this.#playback_tracking) throw new InnertubeError('Playback tracking not available'); @@ -180,7 +188,7 @@ class TrackInfo { return this.tabs ? this.tabs.map((tab) => tab.title) : []; } - get page() { + get page(): [ParsedResponse, ParsedResponse?] { return this.#page; } } diff --git a/src/proto/index.ts b/src/proto/index.ts index 37bcbe7c4..a6ed4b9b5 100644 --- a/src/proto/index.ts +++ b/src/proto/index.ts @@ -5,7 +5,7 @@ import { VideoMetadata } from '../core/Studio'; import { ChannelAnalytics, CreateCommentParams, GetCommentsSectionParams, InnertubePayload, LiveMessageParams, MusicSearchFilter, NotificationPreferences, PeformCommentActionParams, SearchFilter } from './youtube'; class Proto { - static encodeChannelAnalyticsParams(channel_id: string) { + static encodeChannelAnalyticsParams(channel_id: string): string { const buf = ChannelAnalytics.toBinary({ params: { channelId: channel_id @@ -19,7 +19,7 @@ class Proto { type?: 'all' | 'video' | 'channel' | 'playlist' | 'movie', duration?: 'all' | 'short' | 'medium' | 'long', sort_by?: 'relevance' | 'rating' | 'upload_date' | 'view_count' - }) { + }): string { const upload_date = { all: undefined, hour: 1, @@ -85,7 +85,7 @@ class Proto { static encodeMusicSearchFilters(filters: { type?: 'all' | 'song' | 'video' | 'album' | 'playlist' | 'artist' - }) { + }): string { const data: MusicSearchFilter = { filters: { type: {} @@ -100,7 +100,7 @@ class Proto { return encodeURIComponent(u8ToBase64(buf)); } - static encodeMessageParams(channel_id: string, video_id: string) { + static encodeMessageParams(channel_id: string, video_id: string): string { const buf = LiveMessageParams.toBinary({ params: { ids: { @@ -116,7 +116,7 @@ class Proto { static encodeCommentsSectionParams(video_id: string, options: { type?: number, sort_by?: 'TOP_COMMENTS' | 'NEWEST_FIRST' - } = {}) { + } = {}): string { const sort_options = { TOP_COMMENTS: 0, NEWEST_FIRST: 1 @@ -140,7 +140,7 @@ class Proto { return encodeURIComponent(u8ToBase64(buf)); } - static encodeCommentParams(video_id: string) { + static encodeCommentParams(video_id: string): string { const buf = CreateCommentParams.toBinary({ videoId: video_id, params: { @@ -156,7 +156,7 @@ class Proto { video_id?: string, text?: string, target_language?: string - } = {}) { + } = {}): string { const data: PeformCommentActionParams = { type, commentId: args.comment_id || ' ', @@ -183,7 +183,7 @@ class Proto { return encodeURIComponent(u8ToBase64(buf)); } - static encodeNotificationPref(channel_id: string, index: number) { + static encodeNotificationPref(channel_id: string, index: number): string { const buf = NotificationPreferences.toBinary({ channelId: channel_id, prefId: { @@ -195,7 +195,7 @@ class Proto { return encodeURIComponent(u8ToBase64(buf)); } - static encodeVideoMetadataPayload(video_id: string, metadata: VideoMetadata) { + static encodeVideoMetadataPayload(video_id: string, metadata: VideoMetadata): Uint8Array { const data: InnertubePayload = { context: { client: { @@ -257,7 +257,7 @@ class Proto { return buf; } - static encodeCustomThumbnailPayload(video_id: string, bytes: Uint8Array) { + static encodeCustomThumbnailPayload(video_id: string, bytes: Uint8Array): Uint8Array { const data: InnertubePayload = { context: { client: { diff --git a/src/utils/Constants.ts b/src/utils/Constants.ts index a97a59643..2dbbf3bbf 100644 --- a/src/utils/Constants.ts +++ b/src/utils/Constants.ts @@ -47,6 +47,7 @@ export const CLIENTS = Object.freeze({ SDK_VERSION: '29' }, YTSTUDIO_ANDROID: { + NAME: 'ANDROID_CREATOR', VERSION: '22.43.101' }, YTMUSIC_ANDROID: { diff --git a/src/utils/HTTPClient.ts b/src/utils/HTTPClient.ts index cc9d56599..2a2fd5815 100644 --- a/src/utils/HTTPClient.ts +++ b/src/utils/HTTPClient.ts @@ -19,14 +19,14 @@ export default class HTTPClient { this.#fetch = fetch || globalThis.fetch; } - get fetch_function() { + get fetch_function(): FetchFunction { return this.#fetch; } async fetch( input: URL | Request | string, init?: RequestInit & HTTPClientInit - ) { + ): Promise { const innertube_url = Constants.URLS.API.PRODUCTION_1 + this.#session.api_version; const baseURL = init?.baseURL || innertube_url; @@ -128,7 +128,7 @@ export default class HTTPClient { } throw new InnertubeError(`Request to ${response.url} failed with status ${response.status}`, await response.text()); } - #adjustContext(ctx: Context, client: string) { + #adjustContext(ctx: Context, client: string): void { switch (client) { case 'YTMUSIC': ctx.client.clientVersion = Constants.CLIENTS.YTMUSIC.VERSION; @@ -146,9 +146,14 @@ export default class HTTPClient { ctx.client.clientName = Constants.CLIENTS.YTMUSIC_ANDROID.NAME; ctx.client.androidSdkVersion = Constants.CLIENTS.ANDROID.SDK_VERSION; break; + case 'YTSTUDIO_ANDROID': + ctx.client.clientVersion = Constants.CLIENTS.YTSTUDIO_ANDROID.VERSION; + ctx.client.clientFormFactor = 'SMALL_FORM_FACTOR'; + ctx.client.clientName = Constants.CLIENTS.YTSTUDIO_ANDROID.NAME; + ctx.client.androidSdkVersion = Constants.CLIENTS.ANDROID.SDK_VERSION; + break; case 'TV_EMBEDDED': ctx.client.clientVersion = Constants.CLIENTS.TV_EMBEDDED.VERSION; - ctx.client.clientName = Constants.CLIENTS.TV_EMBEDDED.NAME; ctx.client.clientScreen = 'EMBED'; ctx.thirdParty = { embedUrl: Constants.URLS.YT_BASE }; break; diff --git a/src/utils/Utils.ts b/src/utils/Utils.ts index 9a097d411..12d389ab0 100644 --- a/src/utils/Utils.ts +++ b/src/utils/Utils.ts @@ -21,10 +21,7 @@ export class InnertubeError extends Error { } 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 { } @@ -33,7 +30,7 @@ export class SessionError extends Error { } * Compares given objects. May not work correctly for * objects with methods. */ -export function deepCompare(obj1: any, obj2: any) { +export function deepCompare(obj1: any, obj2: any): boolean { const keys = Reflect.ownKeys(obj1); return keys.some((key) => { const is_text = obj2[key]?.constructor.name === 'Text'; @@ -50,7 +47,7 @@ export function deepCompare(obj1: any, obj2: any) { * @param start_string - start string. * @param end_string - end string. */ -export function getStringBetweenStrings(data: string, start_string: string, end_string: string) { +export function getStringBetweenStrings(data: string, start_string: string, end_string: string): string | undefined { const regex = new RegExp(`${escapeStringRegexp(start_string)}(.*?)${escapeStringRegexp(end_string)}`, 's'); const match = data.match(regex); return match ? match[1] : undefined; @@ -72,7 +69,7 @@ export function getRandomUserAgent(type: DeviceCategory): string { return available_agents[random_index]; } -export async function sha1Hash(str: string) { +export async function sha1Hash(str: string): Promise { const SubtleCrypto = getRuntime() === 'node' ? (Reflect.get(module, 'require')('crypto').webcrypto as unknown as Crypto).subtle : window.crypto.subtle; const byteToHex = [ '00', '01', '02', '03', '04', '05', '06', '07', '08', '09', '0a', '0b', '0c', '0d', '0e', '0f', @@ -93,7 +90,7 @@ export async function sha1Hash(str: string) { 'f0', 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'fa', 'fb', 'fc', 'fd', 'fe', 'ff' ]; - function hex(arrayBuffer: ArrayBuffer) { + function hex(arrayBuffer: ArrayBuffer): string { const buff = new Uint8Array(arrayBuffer); const hexOctets = []; for (let i = 0; i < buff.length; ++i) @@ -138,7 +135,7 @@ export function generateRandomString(length: number): string { * Converts time (h:m:s) to seconds. * @returns seconds */ -export function timeToSeconds(time: string) { +export function timeToSeconds(time: string): number { const params = time.split(':').map((param) => parseInt(param)); switch (params.length) { case 1: @@ -152,7 +149,7 @@ export function timeToSeconds(time: string) { } } -export function concatMemos(...iterables: Memo[]) { +export function concatMemos(...iterables: Memo[]): Memo { const memo = new Memo(); for (const iterable of iterables) { @@ -164,7 +161,7 @@ export function concatMemos(...iterables: Memo[]) { return memo; } -export function throwIfMissing(params: object) { +export function throwIfMissing(params: object): void { for (const [ key, value ] of Object.entries(params)) { if (!value) throw new MissingParamError(`${key} is missing`); @@ -179,7 +176,7 @@ export function hasKeys(params: T, ...k return true; } -export function uuidv4() { +export function uuidv4(): string { if (getRuntime() === 'node') { return Reflect.get(module, 'require')('crypto').webcrypto.randomUUID(); } @@ -205,7 +202,7 @@ export function getRuntime(): Runtime { return 'browser'; } -export function isServer() { +export function isServer(): boolean { return [ 'node', 'deno' ].includes(getRuntime()); } @@ -267,6 +264,6 @@ export const debugFetch: FetchFunction = (input, init) => { return globalThis.fetch(input, init); }; -export function u8ToBase64(u8: Uint8Array) { +export function u8ToBase64(u8: Uint8Array): string { return btoa(String.fromCharCode.apply(null, Array.from(u8))); } \ No newline at end of file