Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add TV client endpoints and nodes to get content with default OAuth2 Login #872

Draft
wants to merge 27 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
edabbde
chore: test tv home
Duell10111 Jan 2, 2025
384e743
chore: add tv client for new homefeed
Duell10111 Jan 3, 2025
f071940
chore: add HorizontalList continuation data
Duell10111 Jan 4, 2025
42aa918
chore: add new types to Tile
Duell10111 Jan 4, 2025
23432eb
chore: adapt horizontal fetch function to work with horizontal list a…
Duell10111 Jan 4, 2025
403fd40
chore: add shelf headers for new tv endpoints
Duell10111 Jan 4, 2025
4a674ff
chore: add VideoInfo for tv client
Duell10111 Jan 5, 2025
af290a2
chore: add more video info nodes
Duell10111 Jan 6, 2025
de981cc
chore: add more endpoints for tv client
Duell10111 Jan 8, 2025
80212ed
chore: add my youtube feed integration
Duell10111 Jan 9, 2025
4247d62
chore: add first playlist interaction for tv client
Duell10111 Jan 10, 2025
168126a
chore: add library selection function
Duell10111 Jan 11, 2025
cbce45f
fix: fix issue with adding to library endpoints
Duell10111 Jan 11, 2025
cb8979f
chore: add playlist for tv video info and watch later function with s…
Duell10111 Jan 12, 2025
656576c
chore: enable sourceMap for local debugging
Duell10111 Jan 13, 2025
0750fbe
chore: revert most changes of innertube test file
Duell10111 Jan 14, 2025
9e45bdd
chore: add missing nodes
Duell10111 Jan 15, 2025
ad08278
chore: add update watch next item function
Duell10111 Jan 15, 2025
e4a71b4
chore: cleanup files a bit
Duell10111 Jan 15, 2025
1b28052
chore: lint fixes
Duell10111 Jan 15, 2025
f3c06a9
chore: style adaptions and test adaption
Duell10111 Jan 15, 2025
29680b9
chore: remove update watch history time function and remove session a…
Duell10111 Jan 16, 2025
65c61b6
chore: adapt imports and parseArray functions
Duell10111 Jan 16, 2025
b4e01d3
chore: move endpoints in endpoint folder
Duell10111 Jan 16, 2025
6cda166
chore: revert sourcemap enablement for this pr
Duell10111 Jan 16, 2025
a9e4ab3
chore: remove tracking params
Duell10111 Jan 16, 2025
b1970ff
docs: adapt docs
Duell10111 Jan 16, 2025
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,5 +1,5 @@
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 @@ -582,6 +582,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 @@ -74,6 +74,7 @@ export type Context = {
kidsNoSearchMode: string;
};
};
tvAppInfo?: {[key: string]: any};
};
user: {
enableSafetyMode: boolean;
Expand Down
147 changes: 147 additions & 0 deletions src/core/clients/TV.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +15 to +16
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are saving the session and the actions instances in their on variables but then in various places you still access the actions instance through this#session.actions. Could you please pick one and use it consistently throughout the class.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced to use #action property.


constructor(session: Session) {
this.#session = session;
this.#actions = session.actions;
}

async getInfo(target: string | NavigationEndpoint, client?: InnerTubeClient): 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 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<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> {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the library? If yes, maybe we should just name it getLibrary.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seemed to be an extra browseId "FEmy_youtube", which differed from the normal Library as it contained a left tab bar with a grid on the right, based on the tab selection.

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?.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<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, SuperParsedResult, YTNode } 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';

export default class Feed<T extends IParsedResponse = IParsedResponse> {
Expand Down Expand Up @@ -138,7 +139,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)?.first().content;
const reload_continuation_items = this.#memo.getType(ReloadContinuationItemsCommand).first();
const append_continuation_items = this.#memo.getType(AppendContinuationItemsAction).first();
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);
}
}
5 changes: 3 additions & 2 deletions src/parser/classes/EngagementPanelSectionList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 ? {
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> | null;
bylines: ObservedArray<Line> | null;

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);
}
}
14 changes: 14 additions & 0 deletions src/parser/classes/HorizontalButtonList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { YTNode, type ObservedArray } from '../helpers.js';
import { type RawNode, Parser } from '../index.js';
import Button from './Button.js';

export default class HorizontalButtonList extends YTNode {
static type = 'HorizontalButtonList';

items: ObservedArray<Button> | null;

constructor(data: RawNode) {
super();
this.items = Parser.parse(data.items, true, Button);
}
}
Loading
Loading