Skip to content

Commit 1b77fbb

Browse files
committed
feat: add tv client with classes
1 parent 1d4024f commit 1b77fbb

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

61 files changed

+1665
-21
lines changed

src/Innertube.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import Session from './core/Session.js';
2-
import { Kids, Music, Studio } from './core/clients/index.js';
2+
import { Kids, Music, Studio, TV } from './core/clients/index.js';
33
import { AccountManager, InteractionManager, PlaylistManager } from './core/managers/index.js';
44
import { Feed, TabbedFeed } from './core/mixins/index.js';
55

@@ -590,6 +590,13 @@ export default class Innertube {
590590
return new Kids(this.#session);
591591
}
592592

593+
/**
594+
* An interface for interacting with YouTube TV endpoints.
595+
*/
596+
get tv() {
597+
return new TV(this.#session);
598+
}
599+
593600
/**
594601
* An interface for managing and retrieving account information.
595602
*/

src/core/Session.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export type Context = {
7474
kidsNoSearchMode: string;
7575
};
7676
};
77+
tvAppInfo?: {[key: string]: any};
7778
};
7879
user: {
7980
enableSafetyMode: boolean;

src/core/clients/TV.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { HorizontalListContinuation, type IBrowseResponse, Parser } from '../../parser/index.js';
2+
import type { Actions, Session } from '../index.js';
3+
import type { InnerTubeClient } from '../../types/index.js';
4+
import NavigationEndpoint from '../../parser/classes/NavigationEndpoint.js';
5+
import { HomeFeed, VideoInfo, MyYoutubeFeed } from '../../parser/yttv/index.js';
6+
import { generateRandomString, InnertubeError, throwIfMissing } from '../../utils/Utils.js';
7+
import HorizontalList from '../../parser/classes/HorizontalList.js';
8+
import type { YTNode } from '../../parser/helpers.js';
9+
import Playlist from '../../parser/yttv/Playlist.js';
10+
import Library from '../../parser/yttv/Library.js';
11+
import SubscriptionsFeed from '../../parser/yttv/SubscriptionsFeed.js';
12+
import PlaylistsFeed from '../../parser/yttv/PlaylistsFeed.js';
13+
14+
export default class TV {
15+
#session: Session;
16+
readonly #actions: Actions;
17+
18+
constructor(session: Session) {
19+
this.#session = session;
20+
this.#actions = session.actions;
21+
}
22+
23+
async getInfo(target: string | NavigationEndpoint, client?: InnerTubeClient): Promise<VideoInfo> {
24+
throwIfMissing({ target });
25+
26+
const payload = {
27+
videoId: target instanceof NavigationEndpoint ? target.payload?.videoId : target,
28+
playlistId: target instanceof NavigationEndpoint ? target.payload?.playlistId : undefined,
29+
playlistIndex: target instanceof NavigationEndpoint ? target.payload?.playlistIndex : undefined,
30+
params: target instanceof NavigationEndpoint ? target.payload?.params : undefined,
31+
racyCheckOk: true,
32+
contentCheckOk: true
33+
};
34+
35+
const watch_endpoint = new NavigationEndpoint({ watchEndpoint: payload });
36+
const watch_next_endpoint = new NavigationEndpoint({ watchNextEndpoint: payload });
37+
38+
const watch_response = watch_endpoint.call(this.#actions, {
39+
playbackContext: {
40+
contentPlaybackContext: {
41+
vis: 0,
42+
splay: false,
43+
lactMilliseconds: '-1',
44+
signatureTimestamp: this.#session.player?.sts
45+
}
46+
},
47+
serviceIntegrityDimensions: {
48+
poToken: this.#session.po_token
49+
},
50+
client
51+
});
52+
53+
const watch_next_response = await watch_next_endpoint.call(this.#actions, {
54+
client
55+
});
56+
57+
const response = await Promise.all([ watch_response, watch_next_response ]);
58+
59+
const cpn = generateRandomString(16);
60+
61+
return new VideoInfo(response, this.#actions, cpn);
62+
}
63+
64+
async getHomeFeed(): Promise<HomeFeed> {
65+
const client : InnerTubeClient = 'TV';
66+
const home_feed = new NavigationEndpoint({ browseEndpoint: {
67+
browseId: 'default'
68+
} });
69+
const response = await home_feed.call(this.#actions, {
70+
client
71+
});
72+
return new HomeFeed(response, this.#actions);
73+
}
74+
75+
async getLibrary(): Promise<Library> {
76+
const browse_endpoint = new NavigationEndpoint({ browseEndpoint: { browseId: 'FElibrary' } });
77+
const response = await browse_endpoint.call(this.#actions, {
78+
client: 'TV'
79+
});
80+
return new Library(response, this.#actions);
81+
}
82+
83+
async getSubscriptionsFeed(): Promise<SubscriptionsFeed> {
84+
const browse_endpoint = new NavigationEndpoint({ browseEndpoint: { browseId: 'FEsubscriptions' } });
85+
const response = await browse_endpoint.call(this.#actions, { client: 'TV' });
86+
return new SubscriptionsFeed(response, this.#actions);
87+
}
88+
89+
/**
90+
* Retrieves the user's playlists.
91+
*/
92+
async getPlaylists(): Promise<PlaylistsFeed> {
93+
const browse_endpoint = new NavigationEndpoint({ browseEndpoint: { browseId: 'FEplaylist_aggregation' } });
94+
const response = await browse_endpoint.call(this.#actions, { client: 'TV' });
95+
return new PlaylistsFeed(response, this.#actions);
96+
}
97+
98+
/**
99+
* Retrieves the user's My YouTube page.
100+
*/
101+
async getMyYoutubeFeed(): Promise<MyYoutubeFeed> {
102+
const browse_endpoint = new NavigationEndpoint({ browseEndpoint: { browseId: 'FEmy_youtube' } });
103+
const response = await browse_endpoint.call(this.#actions, { client: 'TV' });
104+
return new MyYoutubeFeed(response, this.#actions);
105+
}
106+
107+
async getPlaylist(id: string): Promise<Playlist> {
108+
throwIfMissing({ id });
109+
110+
if (!id.startsWith('VL')) {
111+
id = `VL${id}`;
112+
}
113+
114+
const browse_endpoint = new NavigationEndpoint({ browseEndpoint: { browseId: id } });
115+
const response = await browse_endpoint.call(this.#actions, {
116+
client: 'TV'
117+
});
118+
119+
return new Playlist(response, this.#actions);
120+
}
121+
122+
// Utils
123+
124+
async fetchContinuationData(item: YTNode, client?: InnerTubeClient) {
125+
let continuation: string | undefined;
126+
127+
if (item.is(HorizontalList)) {
128+
continuation = item.continuations?.first()?.continuation;
129+
} else if (item.is(HorizontalListContinuation)) {
130+
continuation = item.continuation;
131+
} else {
132+
throw new InnertubeError(`No supported YTNode supplied. Type: ${item.type}`);
133+
}
134+
135+
if (!continuation) {
136+
throw new InnertubeError('No continuation data available.');
137+
}
138+
139+
const data = await this.#actions.execute('/browse', {
140+
client: client ?? 'TV',
141+
continuation: continuation
142+
});
143+
144+
const parser = Parser.parseResponse<IBrowseResponse>(data.data);
145+
return parser.continuation_contents;
146+
}
147+
}

src/core/clients/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { default as Kids } from './Kids.js';
22
export { default as Music } from './Music.js';
3+
export { default as TV } from './TV.js';
34
export { default as Studio } from './Studio.js';

src/core/managers/PlaylistManager.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import Playlist from '../../parser/youtube/Playlist.js';
44
import type { Actions } from '../index.js';
55
import type { Feed } from '../mixins/index.js';
66
import NavigationEndpoint from '../../parser/classes/NavigationEndpoint.js';
7+
import type { InnerTubeClient } from '../../types/index.js';
78

89
export default class PlaylistManager {
910
readonly #actions: Actions;
@@ -69,8 +70,9 @@ export default class PlaylistManager {
6970
/**
7071
* Adds a given playlist to the library of a user.
7172
* @param playlist_id - The playlist ID.
73+
* @param client - Innertube client to use for action
7274
*/
73-
async addToLibrary(playlist_id: string){
75+
async addToLibrary(playlist_id: string, client?: InnerTubeClient){
7476
throwIfMissing({ playlist_id });
7577

7678
if (!this.#actions.session.logged_in)
@@ -79,18 +81,23 @@ export default class PlaylistManager {
7981
const like_playlist_endpoint = new NavigationEndpoint({
8082
likeEndpoint: {
8183
status: 'LIKE',
82-
target: playlist_id
84+
target: {
85+
playlistId: playlist_id
86+
}
8387
}
8488
});
8589

86-
return await like_playlist_endpoint.call(this.#actions);
90+
return await like_playlist_endpoint.call(this.#actions, {
91+
client
92+
});
8793
}
8894

8995
/**
9096
* Remove a given playlist to the library of a user.
9197
* @param playlist_id - The playlist ID.
98+
* @param client - Innertube client to use for action
9299
*/
93-
async removeFromLibrary(playlist_id: string){
100+
async removeFromLibrary(playlist_id: string, client?: InnerTubeClient){
94101
throwIfMissing({ playlist_id });
95102

96103
if (!this.#actions.session.logged_in)
@@ -99,19 +106,24 @@ export default class PlaylistManager {
99106
const remove_like_playlist_endpoint = new NavigationEndpoint({
100107
likeEndpoint: {
101108
status: 'INDIFFERENT',
102-
target: playlist_id
109+
target: {
110+
playlistId: playlist_id
111+
}
103112
}
104113
});
105114

106-
return await remove_like_playlist_endpoint.call(this.#actions);
115+
return await remove_like_playlist_endpoint.call(this.#actions, {
116+
client
117+
});
107118
}
108119

109120
/**
110121
* Adds videos to a given playlist.
111122
* @param playlist_id - The playlist ID.
112123
* @param video_ids - An array of video IDs to add to the playlist.
124+
* @param client - Innertube client to use for action
113125
*/
114-
async addVideos(playlist_id: string, video_ids: string[]): Promise<{ playlist_id: string; action_result: any }> {
126+
async addVideos(playlist_id: string, video_ids: string[], client?: InnerTubeClient): Promise<{ playlist_id: string; action_result: any }> {
115127
throwIfMissing({ playlist_id, video_ids });
116128

117129
if (!this.#actions.session.logged_in)
@@ -127,7 +139,9 @@ export default class PlaylistManager {
127139
}
128140
});
129141

130-
const response = await playlist_edit_endpoint.call(this.#actions);
142+
const response = await playlist_edit_endpoint.call(this.#actions, {
143+
client
144+
});
131145

132146
return {
133147
playlist_id,

src/core/mixins/Feed.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import type { Actions, ApiResponse } from '../index.js';
3232
import type { Memo, ObservedArray } from '../../parser/helpers.js';
3333
import type MusicQueue from '../../parser/classes/MusicQueue.js';
3434
import type RichGrid from '../../parser/classes/RichGrid.js';
35+
import type TvSurfaceContent from '../../parser/classes/TvSurfaceContent.js';
3536
import type SectionList from '../../parser/classes/SectionList.js';
3637
import type SecondarySearchContainer from '../../parser/classes/SecondarySearchContainer.js';
3738
import type BrowseFeedActions from '../../parser/classes/BrowseFeedActions.js';
@@ -141,7 +142,7 @@ export default class Feed<T extends IParsedResponse = IParsedResponse> {
141142
/**
142143
* Returns contents from the page.
143144
*/
144-
get page_contents(): SectionList | MusicQueue | RichGrid | ReloadContinuationItemsCommand {
145+
get page_contents(): SectionList | MusicQueue | RichGrid | TvSurfaceContent | ReloadContinuationItemsCommand {
145146
const tab_content = this.#memo.getType(Tab)?.[0].content;
146147
const reload_continuation_items = this.#memo.getType(ReloadContinuationItemsCommand)[0];
147148
const append_continuation_items = this.#memo.getType(AppendContinuationItemsAction)[0];

src/parser/classes/AvatarLockup.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import Text from './misc/Text.js';
2+
import { type RawNode } from '../index.js';
3+
import { YTNode } from '../helpers.js';
4+
5+
export default class AvatarLockup extends YTNode {
6+
static type = 'AvatarLockup';
7+
8+
title: Text;
9+
size: string;
10+
11+
constructor(data: RawNode) {
12+
super();
13+
this.title = new Text(data.title);
14+
this.size = data.size;
15+
}
16+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { YTNode } from '../helpers.js';
2+
import { type RawNode } from '../index.js';
3+
import Text from './misc/Text.js';
4+
import Thumbnail from './misc/Thumbnail.js';
5+
import NavigationEndpoint from './NavigationEndpoint.js';
6+
7+
export default class CommentsEntryPoint extends YTNode {
8+
static type = 'CommentsEntryPoint';
9+
10+
author_thumbnail: Thumbnail[];
11+
author_text: Text;
12+
content_text: Text;
13+
header_text: Text;
14+
comment_count: Text;
15+
endpoint: NavigationEndpoint;
16+
17+
constructor(data: RawNode) {
18+
super();
19+
this.author_thumbnail = Thumbnail.fromResponse(data.authorThumbnail);
20+
this.author_text = new Text(data.authorText);
21+
this.content_text = new Text(data.contentText);
22+
this.header_text = new Text(data.headerText);
23+
this.comment_count = new Text(data.commentCount);
24+
this.endpoint = new NavigationEndpoint(data.onSelectCommand);
25+
}
26+
}

src/parser/classes/EngagementPanelSectionList.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ import ProductList from './ProductList.js';
88
import SectionList from './SectionList.js';
99
import StructuredDescriptionContent from './StructuredDescriptionContent.js';
1010
import VideoAttributeView from './VideoAttributeView.js';
11+
import OverlayPanelHeader from './OverlayPanelHeader.js';
1112

1213
export default class EngagementPanelSectionList extends YTNode {
1314
static type = 'EngagementPanelSectionList';
1415

15-
header: EngagementPanelTitleHeader | null;
16+
header: EngagementPanelTitleHeader | OverlayPanelHeader | null;
1617
content: VideoAttributeView | SectionList | ContinuationItem | ClipSection | StructuredDescriptionContent | MacroMarkersList | ProductList | null;
1718
target_id?: string;
1819
panel_identifier?: string;
@@ -24,7 +25,7 @@ export default class EngagementPanelSectionList extends YTNode {
2425

2526
constructor(data: RawNode) {
2627
super();
27-
this.header = Parser.parseItem(data.header, EngagementPanelTitleHeader);
28+
this.header = Parser.parseItem(data.header, [ EngagementPanelTitleHeader, OverlayPanelHeader ]);
2829
this.content = Parser.parseItem(data.content, [ VideoAttributeView, SectionList, ContinuationItem, ClipSection, StructuredDescriptionContent, MacroMarkersList, ProductList ]);
2930
this.panel_identifier = data.panelIdentifier;
3031
this.identifier = data.identifier ? {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { YTNode, type ObservedArray } from '../helpers.js';
2+
import { Parser, type RawNode } from '../index.js';
3+
import Button from './Button.js';
4+
import ToggleButton from './ToggleButton.js';
5+
import Line from './Line.js';
6+
import Text from './misc/Text.js';
7+
8+
export default class EntityMetadata extends YTNode {
9+
static type = 'EntityMetadata';
10+
11+
title: Text;
12+
description: Text;
13+
buttons: ObservedArray<Button | ToggleButton> | null;
14+
bylines: ObservedArray<Line> | null;
15+
16+
constructor(data: RawNode) {
17+
super();
18+
this.title = new Text(data.title);
19+
this.description = new Text(data.description);
20+
this.buttons = Parser.parseArray(data.buttons, [ Button, ToggleButton ]);
21+
this.bylines = Parser.parseArray(data.bylines, Line);
22+
}
23+
}

0 commit comments

Comments
 (0)