diff --git a/src/Innertube.ts b/src/Innertube.ts index 7e57093d76..c26737ed69 100644 --- a/src/Innertube.ts +++ b/src/Innertube.ts @@ -1,6 +1,6 @@ import Session from './core/Session.js'; -import { Kids, Music, Studio } from './core/clients/index.js'; +import { Kids, Music, Studio, TV } from './core/clients/index.js'; import { AccountManager, InteractionManager, PlaylistManager } from './core/managers/index.js'; import { Feed, TabbedFeed } from './core/mixins/index.js'; @@ -597,6 +597,13 @@ export default class Innertube { return new Kids(this.#session); } + /** + * An interface for interacting with YouTube TV endpoints. + */ + get tv() { + return new TV(this.#session); + } + /** * An interface for managing and retrieving account information. */ diff --git a/src/core/Session.ts b/src/core/Session.ts index 302051a689..bbf764f316 100644 --- a/src/core/Session.ts +++ b/src/core/Session.ts @@ -87,6 +87,7 @@ export type Context = { kidsNoSearchMode: string; }; }; + tvAppInfo?: {[key: string]: any}; }; user: { enableSafetyMode: boolean; diff --git a/src/core/clients/TV.ts b/src/core/clients/TV.ts new file mode 100644 index 0000000000..f314d6e0fc --- /dev/null +++ b/src/core/clients/TV.ts @@ -0,0 +1,156 @@ +import { HorizontalListContinuation, type IBrowseResponse, Parser } from '../../parser/index.js'; +import type { Actions, Session } from '../index.js'; +import type { GetVideoInfoOptions, InnerTubeClient } from '../../types/index.js'; +import { generateRandomString, InnertubeError, throwIfMissing } from '../../utils/Utils.js'; +import NavigationEndpoint from '../../parser/classes/NavigationEndpoint.js'; +import HorizontalList from '../../parser/classes/HorizontalList.js'; +import type { YTNode } from '../../parser/helpers.js'; +import Playlist from '../../parser/yttv/Playlist.js'; +import Library from '../../parser/yttv/Library.js'; +import SubscriptionsFeed from '../../parser/yttv/SubscriptionsFeed.js'; +import PlaylistsFeed from '../../parser/yttv/PlaylistsFeed.js'; +import HomeFeed from '../../parser/yttv/HomeFeed.js'; +import VideoInfo from '../../parser/yttv/VideoInfo.js'; +import MyYoutubeFeed from '../../parser/yttv/MyYoutubeFeed.js'; + +export default class TV { + #session: Session; + readonly #actions: Actions; + + constructor(session: Session) { + this.#session = session; + this.#actions = session.actions; + } + + async getInfo(target: string | NavigationEndpoint, options?: Omit): Promise { + throwIfMissing({ target }); + + const payload = { + videoId: target instanceof NavigationEndpoint ? target.payload?.videoId : target, + playlistId: target instanceof NavigationEndpoint ? target.payload?.playlistId : undefined, + playlistIndex: target instanceof NavigationEndpoint ? target.payload?.playlistIndex : undefined, + params: target instanceof NavigationEndpoint ? target.payload?.params : undefined, + racyCheckOk: true, + contentCheckOk: true + }; + + const watch_endpoint = new NavigationEndpoint({ watchEndpoint: payload }); + const watch_next_endpoint = new NavigationEndpoint({ watchNextEndpoint: payload }); + + const extra_payload: Record = { + playbackContext: { + contentPlaybackContext: { + vis: 0, + splay: false, + lactMilliseconds: '-1', + signatureTimestamp: this.#session.player?.signature_timestamp + } + }, + client: 'TV' + }; + + if (options?.po_token) { + extra_payload.serviceIntegrityDimensions = { + poToken: options.po_token + }; + } else if (this.#session.po_token) { + extra_payload.serviceIntegrityDimensions = { + poToken: this.#session.po_token + }; + } + + const watch_response = watch_endpoint.call(this.#actions, extra_payload); + + const watch_next_response = await watch_next_endpoint.call(this.#actions, { client: 'TV' }); + + const response = await Promise.all([ watch_response, watch_next_response ]); + + const cpn = generateRandomString(16); + + return new VideoInfo(response, this.#actions, cpn); + } + + async getHomeFeed(): Promise { + const client : InnerTubeClient = 'TV'; + const home_feed = new NavigationEndpoint({ browseEndpoint: { + browseId: 'default' + } }); + const response = await home_feed.call(this.#actions, { + client + }); + return new HomeFeed(response, this.#actions); + } + + async getLibrary(): Promise { + const browse_endpoint = new NavigationEndpoint({ browseEndpoint: { browseId: 'FElibrary' } }); + const response = await browse_endpoint.call(this.#actions, { + client: 'TV' + }); + return new Library(response, this.#actions); + } + + async getSubscriptionsFeed(): Promise { + const browse_endpoint = new NavigationEndpoint({ browseEndpoint: { browseId: 'FEsubscriptions' } }); + const response = await browse_endpoint.call(this.#actions, { client: 'TV' }); + return new SubscriptionsFeed(response, this.#actions); + } + + /** + * Retrieves the user's playlists. + */ + async getPlaylists(): Promise { + const browse_endpoint = new NavigationEndpoint({ browseEndpoint: { browseId: 'FEplaylist_aggregation' } }); + const response = await browse_endpoint.call(this.#actions, { client: 'TV' }); + return new PlaylistsFeed(response, this.#actions); + } + + /** + * Retrieves the user's My YouTube page. + */ + async getMyYoutubeFeed(): Promise { + const browse_endpoint = new NavigationEndpoint({ browseEndpoint: { browseId: 'FEmy_youtube' } }); + const response = await browse_endpoint.call(this.#actions, { client: 'TV' }); + return new MyYoutubeFeed(response, this.#actions); + } + + async getPlaylist(id: string): Promise { + throwIfMissing({ id }); + + if (!id.startsWith('VL')) { + id = `VL${id}`; + } + + const browse_endpoint = new NavigationEndpoint({ browseEndpoint: { browseId: id } }); + const response = await browse_endpoint.call(this.#actions, { + client: 'TV' + }); + + return new Playlist(response, this.#actions); + } + + // Utils + + async fetchContinuationData(item: YTNode, client?: InnerTubeClient) { + let continuation: string | undefined; + + if (item.is(HorizontalList)) { + continuation = item.continuations?.[0]?.continuation; + } else if (item.is(HorizontalListContinuation)) { + continuation = item.continuation; + } else { + throw new InnertubeError(`No supported YTNode supplied. Type: ${item.type}`); + } + + if (!continuation) { + throw new InnertubeError('No continuation data available.'); + } + + const data = await this.#actions.execute('/browse', { + client: client ?? 'TV', + continuation: continuation + }); + + const parser = Parser.parseResponse(data.data); + return parser.continuation_contents; + } +} \ No newline at end of file diff --git a/src/core/clients/index.ts b/src/core/clients/index.ts index 6b7fe70544..037b87fa8c 100644 --- a/src/core/clients/index.ts +++ b/src/core/clients/index.ts @@ -1,3 +1,4 @@ export { default as Kids } from './Kids.js'; export { default as Music } from './Music.js'; +export { default as TV } from './TV.js'; export { default as Studio } from './Studio.js'; \ No newline at end of file diff --git a/src/core/managers/PlaylistManager.ts b/src/core/managers/PlaylistManager.ts index 22ab94134e..f9ad2428f7 100644 --- a/src/core/managers/PlaylistManager.ts +++ b/src/core/managers/PlaylistManager.ts @@ -4,6 +4,7 @@ import Playlist from '../../parser/youtube/Playlist.js'; import type { Actions } from '../index.js'; import type { Feed } from '../mixins/index.js'; import NavigationEndpoint from '../../parser/classes/NavigationEndpoint.js'; +import type { InnerTubeClient } from '../../types/index.js'; export default class PlaylistManager { readonly #actions: Actions; @@ -69,8 +70,9 @@ export default class PlaylistManager { /** * Adds a given playlist to the library of a user. * @param playlist_id - The playlist ID. + * @param client - Innertube client to use for action */ - async addToLibrary(playlist_id: string){ + async addToLibrary(playlist_id: string, client?: InnerTubeClient){ throwIfMissing({ playlist_id }); if (!this.#actions.session.logged_in) @@ -79,18 +81,23 @@ export default class PlaylistManager { const like_playlist_endpoint = new NavigationEndpoint({ likeEndpoint: { status: 'LIKE', - target: playlist_id + target: { + playlistId: playlist_id + } } }); - return await like_playlist_endpoint.call(this.#actions); + return await like_playlist_endpoint.call(this.#actions, { + client + }); } /** * Remove a given playlist to the library of a user. * @param playlist_id - The playlist ID. + * @param client - Innertube client to use for action */ - async removeFromLibrary(playlist_id: string){ + async removeFromLibrary(playlist_id: string, client?: InnerTubeClient){ throwIfMissing({ playlist_id }); if (!this.#actions.session.logged_in) @@ -99,19 +106,24 @@ export default class PlaylistManager { const remove_like_playlist_endpoint = new NavigationEndpoint({ likeEndpoint: { status: 'INDIFFERENT', - target: playlist_id + target: { + playlistId: playlist_id + } } }); - return await remove_like_playlist_endpoint.call(this.#actions); + return await remove_like_playlist_endpoint.call(this.#actions, { + client + }); } /** * Adds videos to a given playlist. * @param playlist_id - The playlist ID. * @param video_ids - An array of video IDs to add to the playlist. + * @param client - Innertube client to use for action */ - async addVideos(playlist_id: string, video_ids: string[]): Promise<{ playlist_id: string; action_result: any }> { + async addVideos(playlist_id: string, video_ids: string[], client?: InnerTubeClient): Promise<{ playlist_id: string; action_result: any }> { throwIfMissing({ playlist_id, video_ids }); if (!this.#actions.session.logged_in) @@ -127,7 +139,9 @@ export default class PlaylistManager { } }); - const response = await playlist_edit_endpoint.call(this.#actions); + const response = await playlist_edit_endpoint.call(this.#actions, { + client + }); return { playlist_id, diff --git a/src/core/mixins/Feed.ts b/src/core/mixins/Feed.ts index ddf95e4eb7..71a831b398 100644 --- a/src/core/mixins/Feed.ts +++ b/src/core/mixins/Feed.ts @@ -32,6 +32,7 @@ import type { Actions, ApiResponse } from '../index.js'; import type { Memo, ObservedArray } from '../../parser/helpers.js'; import type MusicQueue from '../../parser/classes/MusicQueue.js'; import type RichGrid from '../../parser/classes/RichGrid.js'; +import type TvSurfaceContent from '../../parser/classes/tv/TvSurfaceContent.js'; import type SectionList from '../../parser/classes/SectionList.js'; import type SecondarySearchContainer from '../../parser/classes/SecondarySearchContainer.js'; import type BrowseFeedActions from '../../parser/classes/BrowseFeedActions.js'; @@ -141,7 +142,7 @@ export default class Feed { /** * Returns contents from the page. */ - get page_contents(): SectionList | MusicQueue | RichGrid | ReloadContinuationItemsCommand { + get page_contents(): SectionList | MusicQueue | RichGrid | TvSurfaceContent | 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]; diff --git a/src/parser/classes/AvatarLockup.ts b/src/parser/classes/AvatarLockup.ts new file mode 100644 index 0000000000..523111d490 --- /dev/null +++ b/src/parser/classes/AvatarLockup.ts @@ -0,0 +1,16 @@ +import Text from './misc/Text.js'; +import { type RawNode } from '../index.js'; +import { YTNode } from '../helpers.js'; + +export default class AvatarLockup extends YTNode { + static type = 'AvatarLockup'; + + title: Text; + size: string; + + constructor(data: RawNode) { + super(); + this.title = new Text(data.title); + this.size = data.size; + } +} \ No newline at end of file diff --git a/src/parser/classes/CommentsEntryPoint.ts b/src/parser/classes/CommentsEntryPoint.ts new file mode 100644 index 0000000000..e4c737f4e4 --- /dev/null +++ b/src/parser/classes/CommentsEntryPoint.ts @@ -0,0 +1,26 @@ +import { YTNode } from '../helpers.js'; +import { type RawNode } from '../index.js'; +import Text from './misc/Text.js'; +import Thumbnail from './misc/Thumbnail.js'; +import NavigationEndpoint from './NavigationEndpoint.js'; + +export default class CommentsEntryPoint extends YTNode { + static type = 'CommentsEntryPoint'; + + author_thumbnail: Thumbnail[]; + author_text: Text; + content_text: Text; + header_text: Text; + comment_count: Text; + endpoint: NavigationEndpoint; + + constructor(data: RawNode) { + super(); + this.author_thumbnail = Thumbnail.fromResponse(data.authorThumbnail); + this.author_text = new Text(data.authorText); + this.content_text = new Text(data.contentText); + this.header_text = new Text(data.headerText); + this.comment_count = new Text(data.commentCount); + this.endpoint = new NavigationEndpoint(data.onSelectCommand); + } +} \ No newline at end of file diff --git a/src/parser/classes/EngagementPanelSectionList.ts b/src/parser/classes/EngagementPanelSectionList.ts index 7f408745fd..b270f029c7 100644 --- a/src/parser/classes/EngagementPanelSectionList.ts +++ b/src/parser/classes/EngagementPanelSectionList.ts @@ -8,12 +8,14 @@ import ProductList from './ProductList.js'; import SectionList from './SectionList.js'; import StructuredDescriptionContent from './StructuredDescriptionContent.js'; import VideoAttributeView from './VideoAttributeView.js'; +import OverlayPanelHeader from './OverlayPanelHeader.js'; +import ItemSection from './ItemSection.js'; export default class EngagementPanelSectionList extends YTNode { static type = 'EngagementPanelSectionList'; - header: EngagementPanelTitleHeader | null; - content: VideoAttributeView | SectionList | ContinuationItem | ClipSection | StructuredDescriptionContent | MacroMarkersList | ProductList | null; + header: EngagementPanelTitleHeader | OverlayPanelHeader | null; + content: VideoAttributeView | ItemSection | SectionList | ContinuationItem | ClipSection | StructuredDescriptionContent | MacroMarkersList | ProductList | null; target_id?: string; panel_identifier?: string; identifier?: { @@ -24,8 +26,8 @@ export default class EngagementPanelSectionList extends YTNode { constructor(data: RawNode) { super(); - this.header = Parser.parseItem(data.header, EngagementPanelTitleHeader); - this.content = Parser.parseItem(data.content, [ VideoAttributeView, SectionList, ContinuationItem, ClipSection, StructuredDescriptionContent, MacroMarkersList, ProductList ]); + this.header = Parser.parseItem(data.header, [ EngagementPanelTitleHeader, OverlayPanelHeader ]); + this.content = Parser.parseItem(data.content, [ VideoAttributeView, ItemSection, SectionList, ContinuationItem, ClipSection, StructuredDescriptionContent, MacroMarkersList, ProductList ]); this.panel_identifier = data.panelIdentifier; this.identifier = data.identifier ? { surface: data.identifier.surface, diff --git a/src/parser/classes/EntityMetadata.ts b/src/parser/classes/EntityMetadata.ts new file mode 100644 index 0000000000..8631fe8f24 --- /dev/null +++ b/src/parser/classes/EntityMetadata.ts @@ -0,0 +1,23 @@ +import { YTNode, type ObservedArray } from '../helpers.js'; +import { Parser, type RawNode } from '../index.js'; +import Button from './Button.js'; +import ToggleButton from './ToggleButton.js'; +import Line from './Line.js'; +import Text from './misc/Text.js'; + +export default class EntityMetadata extends YTNode { + static type = 'EntityMetadata'; + + title: Text; + description: Text; + buttons: ObservedArray