From edabbde56bc5216e71bb8de66132257e164cb1e1 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/27] chore: test tv home --- src/core/Session.ts | 1 + src/utils/Constants.ts | 4 ++-- src/utils/HTTPClient.ts | 6 ++++++ test/main.test.ts | 17 +++++++++++++++-- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/core/Session.ts b/src/core/Session.ts index b29663dcc..29e653686 100644 --- a/src/core/Session.ts +++ b/src/core/Session.ts @@ -74,6 +74,7 @@ export type Context = { kidsNoSearchMode: string; }; }; + tvAppInfo?: {[key: string]: any}; }; user: { enableSafetyMode: boolean; diff --git a/src/utils/Constants.ts b/src/utils/Constants.ts index 46b8feba5..d19a1ca81 100644 --- a/src/utils/Constants.ts +++ b/src/utils/Constants.ts @@ -75,8 +75,8 @@ export const CLIENTS = Object.freeze({ TV: { NAME_ID: '7', NAME: 'TVHTML5', - VERSION: '7.20241016.15.00', - USER_AGENT: 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version' + VERSION: '7.20240424.00.00', + USER_AGENT: 'Mozilla/5.0 (Linux armeabi-v7a; Android 7.1.2; Fire OS 6.0) Cobalt/22.lts.3.306369-gold (unlike Gecko) v8/8.8.278.8-jit gles Starboard/13, Amazon_ATV_mediatek8695_2019/NS6294 (Amazon, AFTMM, Wireless) com.amazon.firetv.youtube/22.3.r2.v66.0' }, TV_EMBEDDED: { NAME_ID: '85', diff --git a/src/utils/HTTPClient.ts b/src/utils/HTTPClient.ts index 18762f3b3..50ad11edd 100644 --- a/src/utils/HTTPClient.ts +++ b/src/utils/HTTPClient.ts @@ -229,6 +229,12 @@ export default class HTTPClient { ctx.client.clientVersion = Constants.CLIENTS.TV.VERSION; ctx.client.clientName = Constants.CLIENTS.TV.NAME; ctx.client.userAgent = Constants.CLIENTS.TV.USER_AGENT; + ctx.client.browserName = 'Cobalt'; + ctx.client.tvAppInfo = { + appQuality: 'TV_APP_QUALITY_FULL_ANIMATION', + zylonLeftNav: true + }; + delete ctx.client.browserVersion; break; } case 'TV_EMBEDDED': diff --git a/test/main.test.ts b/test/main.test.ts index dd38523c8..de353da02 100644 --- a/test/main.test.ts +++ b/test/main.test.ts @@ -1,5 +1,6 @@ import { createWriteStream, existsSync } from 'node:fs'; -import { Innertube, Utils, YT, YTMusic, YTNodes } from '../bundle/node.cjs'; +import { Innertube, Utils, YT, YTMusic, YTNodes, ClientType } from '../bundle/node.cjs'; +import {InnerTubeClient} from "../src/types"; jest.useRealTimers(); @@ -7,7 +8,7 @@ describe('YouTube.js Tests', () => { let innertube: Innertube; beforeAll(async () => { - innertube = await Innertube.create({ generate_session_locally: true }); + innertube = await Innertube.create({ generate_session_locally: false }); }); describe('Main', () => { @@ -150,6 +151,18 @@ describe('YouTube.js Tests', () => { // }); }); + test('Innertube#getHomeFeedTV', async () => { + const client : InnerTubeClient = "TV" + const home_feed = new YTNodes.NavigationEndpoint({ browseEndpoint: { + browseId: 'default' + } }); + const response = await home_feed.call(innertube.session.actions, { + client, + parse: true, + }) + console.log(home_feed); + }); + test('Innertube#getGuide', async () => { const guide = await innertube.getGuide(); expect(guide).toBeDefined(); From 384e7430bd416b51b62834f2dd7b1e3310a2a314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Konstantin=20Spa=CC=88th?= Date: Fri, 3 Jan 2025 22:21:58 +0100 Subject: [PATCH 02/27] chore: add tv client for new homefeed --- src/Innertube.ts | 9 +++- src/core/clients/TV.ts | 25 ++++++++++ src/core/clients/index.ts | 1 + src/parser/classes/Line.ts | 15 ++++++ src/parser/classes/LineItem.ts | 14 ++++++ src/parser/classes/ThumbnailOverlayIcon.ts | 21 ++++++++ src/parser/classes/Tile.ts | 30 ++++++++++++ src/parser/classes/TileHeader.ts | 19 ++++++++ src/parser/classes/TileMetadata.ts | 18 +++++++ src/parser/index.ts | 1 + src/parser/nodes.ts | 6 +++ src/parser/yttv/HomeFeed.ts | 56 ++++++++++++++++++++++ src/parser/yttv/index.ts | 1 + test/main.test.ts | 11 ++++- 14 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 src/core/clients/TV.ts create mode 100644 src/parser/classes/Line.ts create mode 100644 src/parser/classes/LineItem.ts create mode 100644 src/parser/classes/ThumbnailOverlayIcon.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/yttv/HomeFeed.ts create mode 100644 src/parser/yttv/index.ts diff --git a/src/Innertube.ts b/src/Innertube.ts index 4790b9b07..d266be5de 100644 --- a/src/Innertube.ts +++ b/src/Innertube.ts @@ -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'; @@ -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. */ diff --git a/src/core/clients/TV.ts b/src/core/clients/TV.ts new file mode 100644 index 000000000..0fdfae298 --- /dev/null +++ b/src/core/clients/TV.ts @@ -0,0 +1,25 @@ +import type { Actions, Session } from '../index.js'; +import type { InnerTubeClient } from '../../types/index.js'; +import NavigationEndpoint from '../../parser/classes/NavigationEndpoint.js'; +import { HomeFeed } from '../../parser/yttv/index.js'; + +export default class TV { + #session: Session; + readonly #actions: Actions; + + constructor(session: Session) { + this.#session = session; + this.#actions = session.actions; + } + + async getHomeFeed(): Promise { + const client : InnerTubeClient = 'TV'; + const home_feed = new NavigationEndpoint({ browseEndpoint: { + browseId: 'default' + } }); + const response = await home_feed.call(this.#session.actions, { + client + }); + return new HomeFeed(response, this.#actions); + } +} \ No newline at end of file diff --git a/src/core/clients/index.ts b/src/core/clients/index.ts index 6b7fe7054..037b87fa8 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/parser/classes/Line.ts b/src/parser/classes/Line.ts new file mode 100644 index 000000000..18fcaaaba --- /dev/null +++ b/src/parser/classes/Line.ts @@ -0,0 +1,15 @@ +import type { ObservedArray } from '../helpers.js'; +import { YTNode } from '../helpers.js'; +import { Parser, type RawNode } from '../index.js'; +import LineItem from './LineItem.js'; + +export default class Line extends YTNode { + static type = 'Line'; + + items: ObservedArray | null; + + constructor(data: RawNode) { + super(); + this.items = Parser.parse(data.items, true, LineItem); + } +} \ No newline at end of file diff --git a/src/parser/classes/LineItem.ts b/src/parser/classes/LineItem.ts new file mode 100644 index 000000000..d4df9cb3e --- /dev/null +++ b/src/parser/classes/LineItem.ts @@ -0,0 +1,14 @@ +import { YTNode } from '../helpers.js'; +import { type RawNode } from '../index.js'; +import Text from './misc/Text.js'; + +export default class LineItem extends YTNode { + static type = 'LineItem'; + + text: Text; + + constructor(data: RawNode) { + super(); + this.text = new Text(data.text); + } +} \ No newline at end of file diff --git a/src/parser/classes/ThumbnailOverlayIcon.ts b/src/parser/classes/ThumbnailOverlayIcon.ts new file mode 100644 index 000000000..37a2997d7 --- /dev/null +++ b/src/parser/classes/ThumbnailOverlayIcon.ts @@ -0,0 +1,21 @@ +import { type RawNode } from '../index.js'; +import { YTNode } from '../helpers.js'; + +export default class ThumbnailOverlayIcon extends YTNode { + static type = 'ThumbnailOverlayIcon'; + + icon: { + icon_type: string + }; + icon_position: string; + icon_color: string; + + constructor(data: RawNode) { + super(); + this.icon = { + icon_type: data.icon.iconType + }; + this.icon_position = data.iconPosition; + this.icon_color = data.iconColor; + } +} \ No newline at end of file diff --git a/src/parser/classes/Tile.ts b/src/parser/classes/Tile.ts new file mode 100644 index 000000000..467ea1824 --- /dev/null +++ b/src/parser/classes/Tile.ts @@ -0,0 +1,30 @@ +import { YTNode } from '../helpers.js'; +import { Parser, type RawNode } from '../index.js'; +import NavigationEndpoint from './NavigationEndpoint.js'; +import TileMetadata from './TileMetadata.js'; +import TileHeader from './TileHeader.js'; + +export default class Tile extends YTNode { + static type = 'Tile'; + + style: 'TILE_STYLE_YTLR_DEFAULT' | 'TILE_STYLE_YTLR_SHORTS'; + header: TileHeader | null; + on_select_endpoint: NavigationEndpoint; + content_id: string; + content_type: 'TILE_CONTENT_TYPE_VIDEO' | 'TILE_CONTENT_TYPE_SHORTS'; // TODO: Extend? + on_long_press_endpoint: NavigationEndpoint; + metadata?: TileMetadata | null; + on_focus_endpoint?: NavigationEndpoint; + + constructor(data: RawNode) { + super(); + this.style = data.style; + this.header = Parser.parseItem(data.header, TileHeader); + this.on_select_endpoint = new NavigationEndpoint(data.onSelectCommand); + this.content_id = data.contentId; + this.content_type = data.contentType; + this.on_long_press_endpoint = new NavigationEndpoint(data.onLongPressCommand); + this.metadata = Reflect.has(data, 'metadata') ? Parser.parseItem(data.metadata, TileMetadata) : undefined; + this.on_focus_endpoint = Reflect.has(data, 'onFocusCommand') ? new NavigationEndpoint(data.onFocusCommand) : undefined; + } +} \ No newline at end of file diff --git a/src/parser/classes/TileHeader.ts b/src/parser/classes/TileHeader.ts new file mode 100644 index 000000000..a50fe873a --- /dev/null +++ b/src/parser/classes/TileHeader.ts @@ -0,0 +1,19 @@ +import { YTNode, type ObservedArray } from '../helpers.js'; +import { Parser, type RawNode } from '../index.js'; +import Thumbnail from './misc/Thumbnail.js'; +import ThumbnailOverlayTimeStatus from './ThumbnailOverlayTimeStatus.js'; +import TileMetadata from './TileMetadata.js'; +import ThumbnailOverlayIcon from './ThumbnailOverlayIcon.js'; + +export default class TileHeader extends YTNode { + static type = 'TileHeader'; + + thumbnail: Thumbnail[]; + thumbnail_overlays: ObservedArray | null; + + constructor(data: RawNode) { + super(); + this.thumbnail = Thumbnail.fromResponse(data.thumbnail); + this.thumbnail_overlays = Parser.parse(data.thumbnailOverlays, true, [ ThumbnailOverlayTimeStatus, ThumbnailOverlayIcon, TileMetadata ]); + } +} \ No newline at end of file diff --git a/src/parser/classes/TileMetadata.ts b/src/parser/classes/TileMetadata.ts new file mode 100644 index 000000000..2c2a7c478 --- /dev/null +++ b/src/parser/classes/TileMetadata.ts @@ -0,0 +1,18 @@ +import type { ObservedArray } from '../helpers.js'; +import { YTNode } from '../helpers.js'; +import { Parser, type RawNode } from '../index.js'; +import Line from './Line.js'; +import Text from './misc/Text.js'; + +export default class TileMetadata extends YTNode { + static type = 'TileMetadata'; + + title: Text; + lines?: ObservedArray | null; + + constructor(data: RawNode) { + super(); + this.title = new Text(data.title); + this.lines = Reflect.has(data, 'lines') ? Parser.parse(data.lines, true, Line) : undefined; + } +} \ No newline at end of file diff --git a/src/parser/index.ts b/src/parser/index.ts index b6c66497b..09f09812d 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -5,6 +5,7 @@ export * as YT from './youtube/index.js'; export * as YTMusic from './ytmusic/index.js'; export * as YTKids from './ytkids/index.js'; export * as YTShorts from './ytshorts/index.js'; +export * as YTTV from './yttv/index.js'; export * as Helpers from './helpers.js'; export * as Generator from './generator.js'; export * as APIResponseTypes from './types/index.js'; diff --git a/src/parser/nodes.ts b/src/parser/nodes.ts index cdbed1053..30719cd90 100644 --- a/src/parser/nodes.ts +++ b/src/parser/nodes.ts @@ -216,6 +216,8 @@ export { default as ItemSectionTab } from './classes/ItemSectionTab.js'; export { default as ItemSectionTabbedHeader } from './classes/ItemSectionTabbedHeader.js'; export { default as LikeButton } from './classes/LikeButton.js'; export { default as LikeButtonView } from './classes/LikeButtonView.js'; +export { default as Line } from './classes/Line.js'; +export { default as LineItem } from './classes/LineItem.js'; export { default as LiveChat } from './classes/LiveChat.js'; export { default as AddBannerToLiveChatCommand } from './classes/livechat/AddBannerToLiveChatCommand.js'; export { default as AddChatItemAction } from './classes/livechat/AddChatItemAction.js'; @@ -461,6 +463,7 @@ export { default as ThumbnailOverlayBadgeView } from './classes/ThumbnailOverlay export { default as ThumbnailOverlayBottomPanel } from './classes/ThumbnailOverlayBottomPanel.js'; export { default as ThumbnailOverlayEndorsement } from './classes/ThumbnailOverlayEndorsement.js'; export { default as ThumbnailOverlayHoverText } from './classes/ThumbnailOverlayHoverText.js'; +export { default as ThumbnailOverlayIcon } from './classes/ThumbnailOverlayIcon.js'; export { default as ThumbnailOverlayInlineUnplayable } from './classes/ThumbnailOverlayInlineUnplayable.js'; export { default as ThumbnailOverlayLoadingPreview } from './classes/ThumbnailOverlayLoadingPreview.js'; export { default as ThumbnailOverlayNowPlaying } from './classes/ThumbnailOverlayNowPlaying.js'; @@ -471,6 +474,9 @@ export { default as ThumbnailOverlaySidePanel } from './classes/ThumbnailOverlay export { default as ThumbnailOverlayTimeStatus } from './classes/ThumbnailOverlayTimeStatus.js'; export { default as ThumbnailOverlayToggleButton } from './classes/ThumbnailOverlayToggleButton.js'; export { default as ThumbnailView } from './classes/ThumbnailView.js'; +export { default as Tile } from './classes/Tile.js'; +export { default as TileHeader } from './classes/TileHeader.js'; +export { default as TileMetadata } from './classes/TileMetadata.js'; export { default as TimedMarkerDecoration } from './classes/TimedMarkerDecoration.js'; export { default as TitleAndButtonListHeader } from './classes/TitleAndButtonListHeader.js'; export { default as ToggleButton } from './classes/ToggleButton.js'; diff --git a/src/parser/yttv/HomeFeed.ts b/src/parser/yttv/HomeFeed.ts new file mode 100644 index 000000000..dde16489b --- /dev/null +++ b/src/parser/yttv/HomeFeed.ts @@ -0,0 +1,56 @@ +import type { IBrowseResponse } from '../types/index.js'; +import type { Actions, ApiResponse } from '../../core/index.js'; +import type { ObservedArray, YTNode } from '../helpers.js'; +import SectionList from '../classes/SectionList.js'; +import { InnertubeError } from '../../utils/Utils.js'; +import { Parser, SectionListContinuation } from '../index.js'; + +export default class HomeFeed { + readonly #page: IBrowseResponse; + readonly #actions: Actions; + readonly #continuation?: string; + + sections?: ObservedArray; + + constructor(response: ApiResponse, actions: Actions) { + this.#actions = actions; + this.#page = Parser.parseResponse(response.data); + + // TODO: Optimize? + const sectionList = this.#page.contents_memo?.getType(SectionList).firstOfType(SectionList); + + this.sections = sectionList?.contents; + this.#continuation = sectionList?.continuation; + + if (!sectionList) { + if (!this.#page.continuation_contents) { + throw new InnertubeError('Continuation did not have any content.'); + } + + const sectionListContinuation = this.#page.continuation_contents.as(SectionListContinuation); + this.#continuation = sectionListContinuation.continuation; + this.sections = sectionListContinuation.contents ?? undefined; + } + + // TODO: Get continue data from SectionListContinuation + } + + /** + * Retrieves home feed continuation. + */ + async getContinuation(): Promise { + if (!this.#continuation) + throw new InnertubeError('Continuation not found.'); + + const response = await this.#actions.execute('/browse', { + client: 'TV', + continuation: this.#continuation + }); + + return new HomeFeed(response, this.#actions); + } + + get has_continuation(): boolean { + return !!this.#continuation; + } +} \ No newline at end of file diff --git a/src/parser/yttv/index.ts b/src/parser/yttv/index.ts new file mode 100644 index 000000000..99a19538c --- /dev/null +++ b/src/parser/yttv/index.ts @@ -0,0 +1 @@ +export { default as HomeFeed } from './HomeFeed.js'; \ No newline at end of file diff --git a/test/main.test.ts b/test/main.test.ts index de353da02..8e9dbaeff 100644 --- a/test/main.test.ts +++ b/test/main.test.ts @@ -152,7 +152,7 @@ describe('YouTube.js Tests', () => { }); test('Innertube#getHomeFeedTV', async () => { - const client : InnerTubeClient = "TV" + const client = "TV" const home_feed = new YTNodes.NavigationEndpoint({ browseEndpoint: { browseId: 'default' } }); @@ -478,6 +478,15 @@ describe('YouTube.js Tests', () => { }); }); + describe('YouTube TV', () => { + test('Innertube#tv.getHomeFeed', async () => { + const home = await innertube.tv.getHomeFeed() + expect(home).toBeDefined(); + expect(home.sections).toBeDefined(); + expect(home.sections).toBeGreaterThan(0); + }); + }); + describe('YouTube Kids', () => { test('Innertube#kids.getInfo', async () => { const info = await innertube.kids.getInfo('juN8qEgLScw'); From f071940145e9d7c6e148060f62b14bc58a7a12ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Konstantin=20Spa=CC=88th?= Date: Sat, 4 Jan 2025 16:56:00 +0100 Subject: [PATCH 03/27] chore: add HorizontalList continuation data --- src/core/clients/TV.ts | 21 +++++++++++++++++++++ src/parser/classes/HorizontalList.ts | 5 +++++ src/parser/classes/NextContinuationData.ts | 15 +++++++++++++++ src/parser/continuations.ts | 15 +++++++++++++++ src/parser/nodes.ts | 1 + src/parser/parser.ts | 3 +++ src/parser/types/ParsedResponse.ts | 5 +++-- 7 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 src/parser/classes/NextContinuationData.ts diff --git a/src/core/clients/TV.ts b/src/core/clients/TV.ts index 0fdfae298..44be2ea6e 100644 --- a/src/core/clients/TV.ts +++ b/src/core/clients/TV.ts @@ -1,7 +1,11 @@ +import type { IBrowseResponse } from '../../parser/index.js'; +import { 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 } from '../../parser/yttv/index.js'; +import { InnertubeError } from '../../utils/Utils.js'; +import type HorizontalList from '../../parser/classes/HorizontalList.js'; export default class TV { #session: Session; @@ -22,4 +26,21 @@ export default class TV { }); return new HomeFeed(response, this.#actions); } + + async fetchHorizontalContinuationData(horizontalList: HorizontalList) { + const continuationData = horizontalList.continuations?.first(); + + if (!continuationData) { + throw new InnertubeError('No continuation data available.'); + } + + const data = await this.#actions.execute('/browse', { + client: 'TV', + continuation: continuationData.continuation + }); + + const parser = Parser.parseResponse(data.data); + return parser.continuation_contents; + + } } \ No newline at end of file diff --git a/src/parser/classes/HorizontalList.ts b/src/parser/classes/HorizontalList.ts index 729a89b3d..089c8069b 100644 --- a/src/parser/classes/HorizontalList.ts +++ b/src/parser/classes/HorizontalList.ts @@ -1,16 +1,21 @@ import { Parser, type RawNode } from '../index.js'; import { type ObservedArray, YTNode } from '../helpers.js'; +import NextContinuationData from './NextContinuationData.js'; export default class HorizontalList extends YTNode { static type = 'HorizontalList'; visible_item_count: string; items: ObservedArray; + + // TODO: Add continuation data here? + continuations?: ObservedArray; constructor(data: RawNode) { super(); this.visible_item_count = data.visibleItemCount; this.items = Parser.parseArray(data.items); + this.continuations = Reflect.has(data, 'continuations') ? Parser.parseArray(data.continuations, NextContinuationData) : undefined; } // XXX: Alias for consistency. diff --git a/src/parser/classes/NextContinuationData.ts b/src/parser/classes/NextContinuationData.ts new file mode 100644 index 000000000..0fa70dd55 --- /dev/null +++ b/src/parser/classes/NextContinuationData.ts @@ -0,0 +1,15 @@ +import { YTNode } from '../helpers.js'; +import { type RawNode } from '../index.js'; + +export default class NextContinuationData extends YTNode { + static type = 'NextContinuationData'; + + continuation: string; + click_tracking_params: string; + + constructor(data: RawNode) { + super(); + this.continuation = data.continuation; + this.click_tracking_params = data.clickTrackingParams; + } +} \ No newline at end of file diff --git a/src/parser/continuations.ts b/src/parser/continuations.ts index fb48c3b1d..6db40e496 100644 --- a/src/parser/continuations.ts +++ b/src/parser/continuations.ts @@ -77,6 +77,21 @@ export class SectionListContinuation extends YTNode { } } +export class HorizontalListContinuation extends YTNode { + static readonly type = 'horizontalListContinuation'; + + continuation: string; + items: ObservedArray | null; + + constructor(data: RawNode) { + super(); + this.items = Parser.parse(data.items, true); + this.continuation = + data.continuations?.[0]?.nextContinuationData?.continuation || + data.continuations?.[0]?.reloadContinuationData?.continuation || null; + } +} + export class MusicPlaylistShelfContinuation extends YTNode { static readonly type = 'musicPlaylistShelfContinuation'; diff --git a/src/parser/nodes.ts b/src/parser/nodes.ts index 30719cd90..3d3ee17b0 100644 --- a/src/parser/nodes.ts +++ b/src/parser/nodes.ts @@ -344,6 +344,7 @@ export { default as PivotBar } from './classes/mweb/PivotBar.js'; export { default as PivotBarItem } from './classes/mweb/PivotBarItem.js'; export { default as TopbarMenuButton } from './classes/mweb/TopbarMenuButton.js'; export { default as NavigationEndpoint } from './classes/NavigationEndpoint.js'; +export { default as NextContinuationData } from './classes/NextContinuationData.js'; export { default as Notification } from './classes/Notification.js'; export { default as NotificationAction } from './classes/NotificationAction.js'; export { default as PageHeader } from './classes/PageHeader.js'; diff --git a/src/parser/parser.ts b/src/parser/parser.ts index d1c6e6183..e95c5ad26 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -10,6 +10,7 @@ import { Continuation, ContinuationCommand, GridContinuation, + HorizontalListContinuation, ItemSectionContinuation, LiveChatContinuation, MusicPlaylistShelfContinuation, @@ -717,6 +718,8 @@ export function parseLC(data: RawNode) { return new ItemSectionContinuation(data.itemSectionContinuation); if (data.sectionListContinuation) return new SectionListContinuation(data.sectionListContinuation); + if (data.horizontalListContinuation) + return new HorizontalListContinuation(data.horizontalListContinuation); if (data.liveChatContinuation) return new LiveChatContinuation(data.liveChatContinuation); if (data.musicPlaylistShelfContinuation) diff --git a/src/parser/types/ParsedResponse.ts b/src/parser/types/ParsedResponse.ts index 22a78f246..25f417b28 100644 --- a/src/parser/types/ParsedResponse.ts +++ b/src/parser/types/ParsedResponse.ts @@ -2,7 +2,8 @@ import type { Memo, ObservedArray, SuperParsedResult, YTNode } from '../helpers. import type { ReloadContinuationItemsCommand, Continuation, GridContinuation, ItemSectionContinuation, LiveChatContinuation, MusicPlaylistShelfContinuation, MusicShelfContinuation, - PlaylistPanelContinuation, SectionListContinuation, ContinuationCommand, ShowMiniplayerCommand, NavigateAction + PlaylistPanelContinuation, SectionListContinuation, ContinuationCommand, ShowMiniplayerCommand, NavigateAction, + HorizontalListContinuation } from '../index.js'; import type { CpnSource } from './RawResponse.js'; @@ -45,7 +46,7 @@ export interface IParsedResponse { on_response_received_commands?: ObservedArray; on_response_received_commands_memo?: Memo; continuation?: Continuation; - continuation_contents?: ItemSectionContinuation | SectionListContinuation | LiveChatContinuation | MusicPlaylistShelfContinuation | + continuation_contents?: ItemSectionContinuation | SectionListContinuation | HorizontalListContinuation | LiveChatContinuation | MusicPlaylistShelfContinuation | MusicShelfContinuation | GridContinuation | PlaylistPanelContinuation | ContinuationCommand; continuation_contents_memo?: Memo; metadata?: SuperParsedResult; From 42aa918a67648615a12b0d7f0beb6234839e9589 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Konstantin=20Spa=CC=88th?= Date: Sat, 4 Jan 2025 17:21:44 +0100 Subject: [PATCH 04/27] chore: add new types to Tile --- src/parser/classes/Tile.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/parser/classes/Tile.ts b/src/parser/classes/Tile.ts index 467ea1824..00667c2e0 100644 --- a/src/parser/classes/Tile.ts +++ b/src/parser/classes/Tile.ts @@ -7,11 +7,11 @@ import TileHeader from './TileHeader.js'; export default class Tile extends YTNode { static type = 'Tile'; - style: 'TILE_STYLE_YTLR_DEFAULT' | 'TILE_STYLE_YTLR_SHORTS'; + style: 'TILE_STYLE_YTLR_DEFAULT' | 'TILE_STYLE_YTLR_ROUND' | 'TILE_STYLE_YTLR_SHORTS'; header: TileHeader | null; on_select_endpoint: NavigationEndpoint; content_id: string; - content_type: 'TILE_CONTENT_TYPE_VIDEO' | 'TILE_CONTENT_TYPE_SHORTS'; // TODO: Extend? + content_type: 'TILE_CONTENT_TYPE_VIDEO' | 'TILE_CONTENT_TYPE_SHORTS' | 'TILE_CONTENT_TYPE_CHANNEL'; // TODO: Extend? on_long_press_endpoint: NavigationEndpoint; metadata?: TileMetadata | null; on_focus_endpoint?: NavigationEndpoint; From 23432eb6a5bc331d542a2b58c705429ccc9aaa98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Konstantin=20Spa=CC=88th?= Date: Sat, 4 Jan 2025 17:53:33 +0100 Subject: [PATCH 05/27] chore: adapt horizontal fetch function to work with horizontal list and horizontallist continuation item --- src/core/clients/TV.ts | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/core/clients/TV.ts b/src/core/clients/TV.ts index 44be2ea6e..fcb1e5b2e 100644 --- a/src/core/clients/TV.ts +++ b/src/core/clients/TV.ts @@ -1,11 +1,12 @@ -import type { IBrowseResponse } from '../../parser/index.js'; +import { HorizontalListContinuation, type IBrowseResponse } from '../../parser/index.js'; import { 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 } from '../../parser/yttv/index.js'; import { InnertubeError } from '../../utils/Utils.js'; -import type HorizontalList from '../../parser/classes/HorizontalList.js'; +import HorizontalList from '../../parser/classes/HorizontalList.js'; +import type { YTNode } from '../../parser/helpers.js'; export default class TV { #session: Session; @@ -27,20 +28,27 @@ export default class TV { return new HomeFeed(response, this.#actions); } - async fetchHorizontalContinuationData(horizontalList: HorizontalList) { - const continuationData = horizontalList.continuations?.first(); + async fetchContinuationData(item: YTNode, client?: InnerTubeClient) { + let continuation: string | undefined; - if (!continuationData) { + 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: 'TV', - continuation: continuationData.continuation + client: client ?? 'TV', + continuation: continuation }); const parser = Parser.parseResponse(data.data); return parser.continuation_contents; - } } \ No newline at end of file From 403fd40b8b3f0d6cd581aa2725288622c856f14a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Konstantin=20Spa=CC=88th?= Date: Sat, 4 Jan 2025 20:23:22 +0100 Subject: [PATCH 06/27] chore: add shelf headers for new tv endpoints --- src/parser/classes/AvatarLockup.ts | 16 ++++++++++++++++ src/parser/classes/Shelf.ts | 6 ++++++ src/parser/classes/ShelfHeader.ts | 14 ++++++++++++++ src/parser/classes/TileHeader.ts | 5 +++-- src/parser/nodes.ts | 8 ++------ 5 files changed, 41 insertions(+), 8 deletions(-) create mode 100644 src/parser/classes/AvatarLockup.ts create mode 100644 src/parser/classes/ShelfHeader.ts diff --git a/src/parser/classes/AvatarLockup.ts b/src/parser/classes/AvatarLockup.ts new file mode 100644 index 000000000..523111d49 --- /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/Shelf.ts b/src/parser/classes/Shelf.ts index 5a520e648..7a18d6aaa 100644 --- a/src/parser/classes/Shelf.ts +++ b/src/parser/classes/Shelf.ts @@ -3,6 +3,7 @@ import { Parser, type RawNode } from '../index.js'; import NavigationEndpoint from './NavigationEndpoint.js'; import { YTNode } from '../helpers.js'; import Button from './Button.js'; +import ShelfHeader from './ShelfHeader.js'; export default class Shelf extends YTNode { static type = 'Shelf'; @@ -14,6 +15,7 @@ export default class Shelf extends YTNode { menu?: YTNode | null; play_all_button?: Button | null; subtitle?: Text; + header?: ShelfHeader | null; constructor(data: RawNode) { super(); @@ -40,5 +42,9 @@ export default class Shelf extends YTNode { if (Reflect.has(data, 'subtitle')) { this.subtitle = new Text(data.subtitle); } + + if (Reflect.has(data, 'headerRenderer')) { + this.header = Parser.parseItem(data.headerRenderer, ShelfHeader); + } } } \ No newline at end of file diff --git a/src/parser/classes/ShelfHeader.ts b/src/parser/classes/ShelfHeader.ts new file mode 100644 index 000000000..4b6ab8d12 --- /dev/null +++ b/src/parser/classes/ShelfHeader.ts @@ -0,0 +1,14 @@ +import { Parser, type RawNode } from '../index.js'; +import { YTNode } from '../helpers.js'; +import AvatarLockup from "./AvatarLockup.js"; + +export default class ShelfHeader extends YTNode { + static type = 'ShelfHeader'; + + avatar_lockup: AvatarLockup | null; + + constructor(data: RawNode) { + super(); + this.avatar_lockup = Parser.parseItem(data.avatarLockup, AvatarLockup); + } +} \ No newline at end of file diff --git a/src/parser/classes/TileHeader.ts b/src/parser/classes/TileHeader.ts index a50fe873a..edba7e680 100644 --- a/src/parser/classes/TileHeader.ts +++ b/src/parser/classes/TileHeader.ts @@ -4,16 +4,17 @@ import Thumbnail from './misc/Thumbnail.js'; import ThumbnailOverlayTimeStatus from './ThumbnailOverlayTimeStatus.js'; import TileMetadata from './TileMetadata.js'; import ThumbnailOverlayIcon from './ThumbnailOverlayIcon.js'; +import ThumbnailOverlayResumePlayback from './ThumbnailOverlayResumePlayback.js'; export default class TileHeader extends YTNode { static type = 'TileHeader'; thumbnail: Thumbnail[]; - thumbnail_overlays: ObservedArray | null; + thumbnail_overlays: ObservedArray | null; constructor(data: RawNode) { super(); this.thumbnail = Thumbnail.fromResponse(data.thumbnail); - this.thumbnail_overlays = Parser.parse(data.thumbnailOverlays, true, [ ThumbnailOverlayTimeStatus, ThumbnailOverlayIcon, TileMetadata ]); + this.thumbnail_overlays = Parser.parse(data.thumbnailOverlays, true, [ ThumbnailOverlayTimeStatus, ThumbnailOverlayIcon, ThumbnailOverlayResumePlayback, TileMetadata ]); } } \ No newline at end of file diff --git a/src/parser/nodes.ts b/src/parser/nodes.ts index 3d3ee17b0..e8a297285 100644 --- a/src/parser/nodes.ts +++ b/src/parser/nodes.ts @@ -24,7 +24,7 @@ export { default as AlertWithButton } from './classes/AlertWithButton.js'; export { default as AttributionView } from './classes/AttributionView.js'; export { default as AudioOnlyPlayability } from './classes/AudioOnlyPlayability.js'; export { default as AutomixPreviewVideo } from './classes/AutomixPreviewVideo.js'; -export { default as AvatarStackView } from './classes/AvatarStackView.js'; +export { default as AvatarLockup } from './classes/AvatarLockup.js'; export { default as AvatarView } from './classes/AvatarView.js'; export { default as BackgroundPromo } from './classes/BackgroundPromo.js'; export { default as BackstageImage } from './classes/BackstageImage.js'; @@ -223,15 +223,12 @@ export { default as AddBannerToLiveChatCommand } from './classes/livechat/AddBan export { default as AddChatItemAction } from './classes/livechat/AddChatItemAction.js'; export { default as AddLiveChatTickerItemAction } from './classes/livechat/AddLiveChatTickerItemAction.js'; export { default as DimChatItemAction } from './classes/livechat/DimChatItemAction.js'; -export { default as BumperUserEduContentView } from './classes/livechat/items/BumperUserEduContentView.js'; -export { default as CreatorHeartView } from './classes/livechat/items/CreatorHeartView.js'; export { default as LiveChatAutoModMessage } from './classes/livechat/items/LiveChatAutoModMessage.js'; export { default as LiveChatBanner } from './classes/livechat/items/LiveChatBanner.js'; export { default as LiveChatBannerChatSummary } from './classes/livechat/items/LiveChatBannerChatSummary.js'; export { default as LiveChatBannerHeader } from './classes/livechat/items/LiveChatBannerHeader.js'; export { default as LiveChatBannerPoll } from './classes/livechat/items/LiveChatBannerPoll.js'; export { default as LiveChatBannerRedirect } from './classes/livechat/items/LiveChatBannerRedirect.js'; -export { default as LiveChatItemBumperView } from './classes/livechat/items/LiveChatItemBumperView.js'; export { default as LiveChatMembershipItem } from './classes/livechat/items/LiveChatMembershipItem.js'; export { default as LiveChatModeChangeMessage } from './classes/livechat/items/LiveChatModeChangeMessage.js'; export { default as LiveChatPaidMessage } from './classes/livechat/items/LiveChatPaidMessage.js'; @@ -247,7 +244,6 @@ export { default as LiveChatTickerPaidMessageItem } from './classes/livechat/ite export { default as LiveChatTickerPaidStickerItem } from './classes/livechat/items/LiveChatTickerPaidStickerItem.js'; export { default as LiveChatTickerSponsorItem } from './classes/livechat/items/LiveChatTickerSponsorItem.js'; export { default as LiveChatViewerEngagementMessage } from './classes/livechat/items/LiveChatViewerEngagementMessage.js'; -export { default as PdgReplyButtonView } from './classes/livechat/items/PdgReplyButtonView.js'; export { default as PollHeader } from './classes/livechat/items/PollHeader.js'; export { default as LiveChatActionPanel } from './classes/livechat/LiveChatActionPanel.js'; export { default as MarkChatItemAsDeletedAction } from './classes/livechat/MarkChatItemAsDeletedAction.js'; @@ -256,7 +252,6 @@ export { default as RemoveBannerForLiveChatCommand } from './classes/livechat/Re export { default as RemoveChatItemAction } from './classes/livechat/RemoveChatItemAction.js'; export { default as RemoveChatItemByAuthorAction } from './classes/livechat/RemoveChatItemByAuthorAction.js'; export { default as ReplaceChatItemAction } from './classes/livechat/ReplaceChatItemAction.js'; -export { default as ReplaceLiveChatAction } from './classes/livechat/ReplaceLiveChatAction.js'; export { default as ReplayChatItemAction } from './classes/livechat/ReplayChatItemAction.js'; export { default as ShowLiveChatActionPanelAction } from './classes/livechat/ShowLiveChatActionPanelAction.js'; export { default as ShowLiveChatDialogAction } from './classes/livechat/ShowLiveChatDialogAction.js'; @@ -429,6 +424,7 @@ export { default as SharePanelHeader } from './classes/SharePanelHeader.js'; export { default as SharePanelTitleV15 } from './classes/SharePanelTitleV15.js'; export { default as ShareTarget } from './classes/ShareTarget.js'; export { default as Shelf } from './classes/Shelf.js'; +export { default as ShelfHeader } from './classes/ShelfHeader.js'; export { default as ShortsLockupView } from './classes/ShortsLockupView.js'; export { default as ShowCustomThumbnail } from './classes/ShowCustomThumbnail.js'; export { default as ShowingResultsFor } from './classes/ShowingResultsFor.js'; From 4a674ffaa3e0dafeb66b6a6844a4d26d2d3795a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Konstantin=20Spa=CC=88th?= Date: Mon, 6 Jan 2025 00:29:12 +0100 Subject: [PATCH 07/27] chore: add VideoInfo for tv client --- src/core/clients/TV.ts | 45 ++- src/parser/classes/AutonavEndpoint.ts | 14 + .../classes/EngagementPanelSectionList.ts | 5 +- src/parser/classes/HorizontalButtonList.ts | 14 + src/parser/classes/LikeButton.ts | 4 + src/parser/classes/MaybeHistoryEndpoint.ts | 21 ++ src/parser/classes/OverlayPanelHeader.ts | 21 ++ src/parser/classes/PreviewButton.ts | 21 ++ .../classes/SingleColumnWatchNextResults.ts | 60 ++++ src/parser/classes/VideoBadgeView.ts | 17 ++ src/parser/classes/VideoMetadata.ts | 47 +++ src/parser/nodes.ts | 8 + src/parser/yttv/VideoInfo.ts | 277 ++++++++++++++++++ src/parser/yttv/index.ts | 3 +- 14 files changed, 552 insertions(+), 5 deletions(-) create mode 100644 src/parser/classes/AutonavEndpoint.ts create mode 100644 src/parser/classes/HorizontalButtonList.ts create mode 100644 src/parser/classes/MaybeHistoryEndpoint.ts create mode 100644 src/parser/classes/OverlayPanelHeader.ts create mode 100644 src/parser/classes/PreviewButton.ts create mode 100644 src/parser/classes/SingleColumnWatchNextResults.ts create mode 100644 src/parser/classes/VideoBadgeView.ts create mode 100644 src/parser/classes/VideoMetadata.ts create mode 100644 src/parser/yttv/VideoInfo.ts diff --git a/src/core/clients/TV.ts b/src/core/clients/TV.ts index fcb1e5b2e..c17142ac2 100644 --- a/src/core/clients/TV.ts +++ b/src/core/clients/TV.ts @@ -3,8 +3,8 @@ import { 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 } from '../../parser/yttv/index.js'; -import { InnertubeError } from '../../utils/Utils.js'; +import { HomeFeed, VideoInfo } 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'; @@ -17,6 +17,47 @@ export default class TV { 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.#session.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.#session.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: { diff --git a/src/parser/classes/AutonavEndpoint.ts b/src/parser/classes/AutonavEndpoint.ts new file mode 100644 index 000000000..1114d57d2 --- /dev/null +++ b/src/parser/classes/AutonavEndpoint.ts @@ -0,0 +1,14 @@ +import { YTNode } from '../helpers.js'; +import { type RawNode } from '../index.js'; +import NavigationEndpoint from './NavigationEndpoint.js'; + +export default class AutonavEndpoint extends YTNode { + static type = 'AutonavEndpoint'; + + endpoint: NavigationEndpoint; + + constructor(data: RawNode) { + super(); + this.endpoint = new NavigationEndpoint(data.endpoint); + } +} \ No newline at end of file diff --git a/src/parser/classes/EngagementPanelSectionList.ts b/src/parser/classes/EngagementPanelSectionList.ts index 7f408745f..5e4f674a6 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/HorizontalButtonList.ts b/src/parser/classes/HorizontalButtonList.ts new file mode 100644 index 000000000..04f6cad69 --- /dev/null +++ b/src/parser/classes/HorizontalButtonList.ts @@ -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