From 3564b956bfe01cad5c07238ae1dee61eec7906f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Konstantin=20Spa=CC=88th?= Date: Thu, 2 Jan 2025 19:11:41 +0100 Subject: [PATCH 01/15] feat: add tv client with classes --- src/Innertube.ts | 9 +- src/core/Session.ts | 1 + src/core/clients/TV.ts | 147 ++++++++++++++++++ src/core/clients/index.ts | 1 + src/core/managers/PlaylistManager.ts | 30 +++- src/core/mixins/Feed.ts | 3 +- src/parser/classes/AvatarLockup.ts | 16 ++ src/parser/classes/CommentsEntryPoint.ts | 26 ++++ .../classes/EngagementPanelSectionList.ts | 5 +- src/parser/classes/EntityMetadata.ts | 23 +++ src/parser/classes/HorizontalButtonList.ts | 14 ++ src/parser/classes/HorizontalList.ts | 6 + src/parser/classes/LikeButton.ts | 4 + src/parser/classes/Line.ts | 14 ++ src/parser/classes/LineItem.ts | 14 ++ src/parser/classes/MacroMarkersListView.ts | 19 +++ src/parser/classes/MaybeHistoryEndpoint.ts | 21 +++ src/parser/classes/NextContinuationData.ts | 15 ++ src/parser/classes/OverlayPanelHeader.ts | 21 +++ src/parser/classes/PivotVideo.ts | 39 +++++ src/parser/classes/PlaylistVideoList.ts | 9 ++ src/parser/classes/PreviewButton.ts | 21 +++ src/parser/classes/QrCode.ts | 16 ++ src/parser/classes/ReloadContinuationData.ts | 13 ++ src/parser/classes/Shelf.ts | 6 + src/parser/classes/ShelfHeader.ts | 14 ++ .../classes/SingleColumnWatchNextResults.ts | 95 +++++++++++ src/parser/classes/Tab.ts | 5 +- src/parser/classes/ThumbnailOverlayIcon.ts | 21 +++ .../classes/ThumbnailOverlayStackingEffect.ts | 15 ++ src/parser/classes/Tile.ts | 30 ++++ src/parser/classes/TileHeader.ts | 23 +++ src/parser/classes/TileMetadata.ts | 17 ++ src/parser/classes/TvBrowse.ts | 15 ++ src/parser/classes/TvSecondaryNav.ts | 17 ++ src/parser/classes/TvSecondaryNavSection.ts | 14 ++ src/parser/classes/TvSurfaceContent.ts | 16 ++ src/parser/classes/TwoColumn.ts | 17 ++ src/parser/classes/VideoBadgeView.ts | 17 ++ .../classes/VideoDescriptionChannelSection.ts | 14 ++ .../VideoDescriptionCommentsSection.ts | 14 ++ src/parser/classes/VideoMetadata.ts | 47 ++++++ .../classes/endpoints/AutonavEndpoint.ts | 14 ++ .../classes/endpoints/AutoplayEndpoint.ts | 14 ++ src/parser/continuations.ts | 41 +++++ src/parser/index.ts | 1 + src/parser/parser.ts | 11 +- src/parser/types/ParsedResponse.ts | 7 +- src/parser/ytmusic/TrackInfo.ts | 3 +- src/parser/yttv/HomeFeed.ts | 54 +++++++ src/parser/yttv/Library.ts | 97 ++++++++++++ src/parser/yttv/MyYoutubeFeed.ts | 94 +++++++++++ src/parser/yttv/Playlist.ts | 76 +++++++++ src/parser/yttv/PlaylistsFeed.ts | 63 ++++++++ src/parser/yttv/SubscriptionsFeed.ts | 67 ++++++++ src/parser/yttv/VideoInfo.ts | 122 +++++++++++++++ src/parser/yttv/index.ts | 7 + src/utils/HTTPClient.ts | 6 + tests/main.test.ts | 84 ++++++++++ 59 files changed, 1626 insertions(+), 19 deletions(-) create mode 100644 src/core/clients/TV.ts create mode 100644 src/parser/classes/AvatarLockup.ts create mode 100644 src/parser/classes/CommentsEntryPoint.ts create mode 100644 src/parser/classes/EntityMetadata.ts create mode 100644 src/parser/classes/HorizontalButtonList.ts create mode 100644 src/parser/classes/Line.ts create mode 100644 src/parser/classes/LineItem.ts create mode 100644 src/parser/classes/MacroMarkersListView.ts create mode 100644 src/parser/classes/MaybeHistoryEndpoint.ts create mode 100644 src/parser/classes/NextContinuationData.ts create mode 100644 src/parser/classes/OverlayPanelHeader.ts create mode 100644 src/parser/classes/PivotVideo.ts create mode 100644 src/parser/classes/PreviewButton.ts create mode 100644 src/parser/classes/QrCode.ts create mode 100644 src/parser/classes/ReloadContinuationData.ts create mode 100644 src/parser/classes/ShelfHeader.ts create mode 100644 src/parser/classes/SingleColumnWatchNextResults.ts create mode 100644 src/parser/classes/ThumbnailOverlayIcon.ts create mode 100644 src/parser/classes/ThumbnailOverlayStackingEffect.ts create mode 100644 src/parser/classes/Tile.ts create mode 100644 src/parser/classes/TileHeader.ts create mode 100644 src/parser/classes/TileMetadata.ts create mode 100644 src/parser/classes/TvBrowse.ts create mode 100644 src/parser/classes/TvSecondaryNav.ts create mode 100644 src/parser/classes/TvSecondaryNavSection.ts create mode 100644 src/parser/classes/TvSurfaceContent.ts create mode 100644 src/parser/classes/TwoColumn.ts create mode 100644 src/parser/classes/VideoBadgeView.ts create mode 100644 src/parser/classes/VideoDescriptionChannelSection.ts create mode 100644 src/parser/classes/VideoDescriptionCommentsSection.ts create mode 100644 src/parser/classes/VideoMetadata.ts create mode 100644 src/parser/classes/endpoints/AutonavEndpoint.ts create mode 100644 src/parser/classes/endpoints/AutoplayEndpoint.ts create mode 100644 src/parser/yttv/HomeFeed.ts create mode 100644 src/parser/yttv/Library.ts create mode 100644 src/parser/yttv/MyYoutubeFeed.ts create mode 100644 src/parser/yttv/Playlist.ts create mode 100644 src/parser/yttv/PlaylistsFeed.ts create mode 100644 src/parser/yttv/SubscriptionsFeed.ts create mode 100644 src/parser/yttv/VideoInfo.ts create mode 100644 src/parser/yttv/index.ts 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..4cb2a4606e --- /dev/null +++ b/src/core/clients/TV.ts @@ -0,0 +1,147 @@ +import { HorizontalListContinuation, type IBrowseResponse, Parser } from '../../parser/index.js'; +import type { Actions, Session } from '../index.js'; +import type { InnerTubeClient } from '../../types/index.js'; +import NavigationEndpoint from '../../parser/classes/NavigationEndpoint.js'; +import { HomeFeed, VideoInfo, MyYoutubeFeed } from '../../parser/yttv/index.js'; +import { generateRandomString, InnertubeError, throwIfMissing } from '../../utils/Utils.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'; + +export default class TV { + #session: Session; + readonly #actions: Actions; + + constructor(session: Session) { + this.#session = session; + this.#actions = session.actions; + } + + async getInfo(target: string | NavigationEndpoint, client?: InnerTubeClient): 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 watch_response = watch_endpoint.call(this.#actions, { + playbackContext: { + contentPlaybackContext: { + vis: 0, + splay: false, + lactMilliseconds: '-1', + signatureTimestamp: this.#session.player?.sts + } + }, + serviceIntegrityDimensions: { + poToken: this.#session.po_token + }, + client + }); + + const watch_next_response = await watch_next_endpoint.call(this.#actions, { + client + }); + + 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?.first()?.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..cba0a53c7d 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/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..5e4f674a6f 100644 --- a/src/parser/classes/EngagementPanelSectionList.ts +++ b/src/parser/classes/EngagementPanelSectionList.ts @@ -8,11 +8,12 @@ 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'; export default class EngagementPanelSectionList extends YTNode { static type = 'EngagementPanelSectionList'; - header: EngagementPanelTitleHeader | null; + header: EngagementPanelTitleHeader | OverlayPanelHeader | null; content: VideoAttributeView | SectionList | ContinuationItem | ClipSection | StructuredDescriptionContent | MacroMarkersList | ProductList | null; target_id?: string; panel_identifier?: string; @@ -24,7 +25,7 @@ export default class EngagementPanelSectionList extends YTNode { constructor(data: RawNode) { super(); - this.header = Parser.parseItem(data.header, EngagementPanelTitleHeader); + this.header = Parser.parseItem(data.header, [ EngagementPanelTitleHeader, OverlayPanelHeader ]); this.content = Parser.parseItem(data.content, [ VideoAttributeView, SectionList, ContinuationItem, ClipSection, StructuredDescriptionContent, MacroMarkersList, ProductList ]); this.panel_identifier = data.panelIdentifier; this.identifier = data.identifier ? { diff --git a/src/parser/classes/EntityMetadata.ts b/src/parser/classes/EntityMetadata.ts new file mode 100644 index 0000000000..36d36736c4 --- /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