Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/Innertube.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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.
*/
Expand Down
1 change: 1 addition & 0 deletions src/core/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export type Context = {
kidsNoSearchMode: string;
};
};
tvAppInfo?: {[key: string]: any};
};
user: {
enableSafetyMode: boolean;
Expand Down
156 changes: 156 additions & 0 deletions src/core/clients/TV.ts
Original file line number Diff line number Diff line change
@@ -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<GetVideoInfoOptions, 'client'>): Promise<VideoInfo> {
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<string, any> = {
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<HomeFeed> {
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<Library> {
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<SubscriptionsFeed> {
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<PlaylistsFeed> {
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<MyYoutubeFeed> {
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<Playlist> {
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<IBrowseResponse>(data.data);
return parser.continuation_contents;
}
}
1 change: 1 addition & 0 deletions src/core/clients/index.ts
Original file line number Diff line number Diff line change
@@ -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';
30 changes: 22 additions & 8 deletions src/core/managers/PlaylistManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion src/core/mixins/Feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -141,7 +142,7 @@ export default class Feed<T extends IParsedResponse = IParsedResponse> {
/**
* 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];
Expand Down
16 changes: 16 additions & 0 deletions src/parser/classes/AvatarLockup.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
26 changes: 26 additions & 0 deletions src/parser/classes/CommentsEntryPoint.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
10 changes: 6 additions & 4 deletions src/parser/classes/EngagementPanelSectionList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?: {
Expand All @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions src/parser/classes/EntityMetadata.ts
Original file line number Diff line number Diff line change
@@ -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<Button | ToggleButton>;
bylines: ObservedArray<Line>;

constructor(data: RawNode) {
super();
this.title = new Text(data.title);
this.description = new Text(data.description);
this.buttons = Parser.parseArray(data.buttons, [ Button, ToggleButton ]);
this.bylines = Parser.parseArray(data.bylines, Line);
}
}
Loading
Loading