Compare commits

...

8 Commits

Author SHA1 Message Date
github-actions[bot]
1d4024fae5 chore(main): release 13.2.0 (#910)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-03-20 07:55:16 -03:00
absidue
216cb3139a chore(parser): Pass playlist_item_data as a parameter instead of a private property (#914) 2025-03-20 07:47:35 -03:00
absidue
4a4c37001d refactor(internal): Reduce frequent accesses to the same private property (#915) 2025-03-20 07:46:36 -03:00
wukko
219d88b200 fix(Constants): Update the iOS client version (#924)
* utils/Constants: update the iOS client version

* utils/HTTPClient: use osName & osVersion for iOS client from Constants
2025-03-20 07:45:17 -03:00
Luan
923e9c28e3 feat: Add AccessibilityContext and CommandContext classes + improve type definitions and parsing logic across multiple nodes 2025-03-03 03:22:27 -03:00
dependabot[bot]
5ef7ea8034 chore(deps-dev): bump vite in /examples/browser/web (#906)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.11 to 5.4.12.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.12/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.12/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-27 04:52:52 -03:00
absidue
00c199ac69 fix(Format): Parse xtags from protobuf to support SABR-only responses (#909) 2025-02-27 03:30:43 -03:00
Nansess
06a0090ae6 chore(constants): Update version numbers to latest (#908) 2025-02-25 07:46:31 +01:00
41 changed files with 643 additions and 290 deletions

View File

@@ -1,5 +1,18 @@
# Changelog
## [13.2.0](https://github.com/LuanRT/YouTube.js/compare/v13.1.0...v13.2.0) (2025-03-20)
### Features
* Add AccessibilityContext and CommandContext classes + improve type definitions and parsing logic across multiple nodes ([923e9c2](https://github.com/LuanRT/YouTube.js/commit/923e9c28e34b00841413824d82d10bf644186edc))
### Bug Fixes
* **Constants:** Update the iOS client version ([#924](https://github.com/LuanRT/YouTube.js/issues/924)) ([219d88b](https://github.com/LuanRT/YouTube.js/commit/219d88b2005431c6697f04e1fa2c5e8528a9ce57))
* **Format:** Parse xtags from protobuf to support SABR-only responses ([#909](https://github.com/LuanRT/YouTube.js/issues/909)) ([00c199a](https://github.com/LuanRT/YouTube.js/commit/00c199ac69bc6d7be19aeae04a245f30b64272c2))
## [13.1.0](https://github.com/LuanRT/YouTube.js/compare/v13.0.0...v13.1.0) (2025-02-21)

View File

@@ -17,7 +17,7 @@
"devDependencies": {
"patch-package": "^6.5.1",
"typescript": "^4.6.4",
"vite": "^5.4.7"
"vite": "^5.4.12"
}
},
"node_modules/@bufbuild/protobuf": {
@@ -1412,10 +1412,11 @@
}
},
"node_modules/vite": {
"version": "5.4.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz",
"integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==",
"version": "5.4.12",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.12.tgz",
"integrity": "sha512-KwUaKB27TvWwDJr1GjjWthLMATbGEbeWYZIbGZ5qFIsgPP3vWzLu4cVooqhm5/Z2SPDUMjyPVjTztm5tYKwQxA==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",

View File

@@ -12,7 +12,7 @@
"devDependencies": {
"patch-package": "^6.5.1",
"typescript": "^4.6.4",
"vite": "^5.4.7"
"vite": "^5.4.12"
},
"dependencies": {
"bgutils-js": "^3.1.2",

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "youtubei.js",
"version": "13.1.0",
"version": "13.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "youtubei.js",
"version": "13.1.0",
"version": "13.2.0",
"funding": [
"https://github.com/sponsors/LuanRT"
],

View File

@@ -1,6 +1,6 @@
{
"name": "youtubei.js",
"version": "13.1.0",
"version": "13.2.0",
"description": "A JavaScript client for YouTube's private API, known as InnerTube.",
"type": "module",
"types": "./dist/src/platform/lib.d.ts",

View File

@@ -35,6 +35,10 @@ export interface KeyValuePair {
value?: string | undefined;
}
export interface FormatXTags {
xtags: KeyValuePair[];
}
function createBaseHttpHeader(): HttpHeader {
return { name: undefined, value: undefined };
}
@@ -275,6 +279,42 @@ export const KeyValuePair: MessageFns<KeyValuePair> = {
},
};
function createBaseFormatXTags(): FormatXTags {
return { xtags: [] };
}
export const FormatXTags: MessageFns<FormatXTags> = {
encode(message: FormatXTags, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
for (const v of message.xtags) {
KeyValuePair.encode(v!, writer.uint32(10).fork()).join();
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): FormatXTags {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseFormatXTags();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1:
if (tag !== 10) {
break;
}
message.xtags.push(KeyValuePair.decode(reader, reader.uint32()));
continue;
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
};
function longToNumber(int64: { toString(): string }): number {
const num = globalThis.Number(int64.toString());
if (num > globalThis.Number.MAX_SAFE_INTEGER) {

View File

@@ -25,4 +25,8 @@ message IndexRange {
message KeyValuePair {
optional string key = 1;
optional string value = 2;
}
message FormatXTags {
repeated KeyValuePair xtags = 1;
}

View File

@@ -85,32 +85,34 @@ export default class Innertube {
const watch_endpoint = new NavigationEndpoint({ watchEndpoint: payload });
const watch_next_endpoint = new NavigationEndpoint({ watchNextEndpoint: payload });
const session = this.#session;
const extra_payload: Record<string, any> = {
playbackContext: {
contentPlaybackContext: {
vis: 0,
splay: false,
lactMilliseconds: '-1',
signatureTimestamp: this.#session.player?.sts
signatureTimestamp: session.player?.sts
}
},
client
};
if (this.#session.po_token) {
if (session.po_token) {
extra_payload.serviceIntegrityDimensions = {
poToken: this.#session.po_token
poToken: session.po_token
};
}
const watch_response = watch_endpoint.call(this.#session.actions, extra_payload);
const watch_next_response = watch_next_endpoint.call(this.#session.actions);
const watch_response = watch_endpoint.call(session.actions, extra_payload);
const watch_next_response = watch_next_endpoint.call(session.actions);
const response = await Promise.all([ watch_response, watch_next_response ]);
const cpn = generateRandomString(16);
return new VideoInfo(response, this.actions, cpn);
return new VideoInfo(response, session.actions, cpn);
}
async getBasicInfo(video_id: string, client?: InnerTubeClient): Promise<VideoInfo> {
@@ -118,29 +120,31 @@ export default class Innertube {
const watch_endpoint = new NavigationEndpoint({ watchEndpoint: { videoId: video_id } });
const session = this.#session;
const extra_payload: Record<string, any> = {
playbackContext: {
contentPlaybackContext: {
vis: 0,
splay: false,
lactMilliseconds: '-1',
signatureTimestamp: this.#session.player?.sts
signatureTimestamp: session.player?.sts
}
},
client
};
if (this.#session.po_token) {
if (session.po_token) {
extra_payload.serviceIntegrityDimensions = {
poToken: this.#session.po_token
poToken: session.po_token
};
}
const watch_response = await watch_endpoint.call(this.#session.actions, extra_payload);
const watch_response = await watch_endpoint.call(session.actions, extra_payload);
const cpn = generateRandomString(16);
return new VideoInfo([ watch_response ], this.actions, cpn);
return new VideoInfo([ watch_response ], session.actions, cpn);
}
async getShortsVideoInfo(video_id: string, client?: InnerTubeClient): Promise<ShortFormVideoInfo> {
@@ -154,7 +158,9 @@ export default class Innertube {
}
});
const reel_watch_response = reel_watch_endpoint.call(this.#session.actions, { client });
const actions = this.#session.actions;
const reel_watch_response = reel_watch_endpoint.call(actions, { client });
const writer = ReelSequence.encode({
shortId: video_id,
@@ -167,13 +173,13 @@ export default class Innertube {
const params = encodeURIComponent(u8ToBase64(writer.finish()));
const sequence_response = this.actions.execute('/reel/reel_watch_sequence', { sequenceParams: params });
const sequence_response = actions.execute('/reel/reel_watch_sequence', { sequenceParams: params });
const response = await Promise.all([ reel_watch_response, sequence_response ]);
const cpn = generateRandomString(16);
return new ShortFormVideoInfo([ response[0] ], this.actions, cpn, response[1]);
return new ShortFormVideoInfo([ response[0] ], actions, cpn, response[1]);
}
async search(query: string, filters: SearchFilters = {}): Promise<Search> {
@@ -253,6 +259,8 @@ export default class Innertube {
}
async getSearchSuggestions(query: string, previous_query?: string): Promise<string[]> {
const session = this.#session;
const url = new URL(`${Constants.URLS.YT_SUGGESTIONS}/complete/search`);
url.searchParams.set('client', 'youtube');
url.searchParams.set('gs_ri', 'youtube');
@@ -260,16 +268,16 @@ export default class Innertube {
url.searchParams.set('cp', '0');
url.searchParams.set('ds', 'yt');
url.searchParams.set('sugexp', Constants.CLIENTS.WEB.SUGG_EXP_ID);
url.searchParams.set('hl', this.#session.context.client.hl);
url.searchParams.set('gl', this.#session.context.client.gl);
url.searchParams.set('hl', session.context.client.hl);
url.searchParams.set('gl', session.context.client.gl);
url.searchParams.set('q', query);
if (previous_query)
url.searchParams.set('pq', previous_query);
const response = await this.#session.http.fetch_function(url, {
const response = await session.http.fetch_function(url, {
headers: {
'Cookie': this.#session.cookie || ''
'Cookie': session.cookie || ''
}
});

View File

@@ -24,32 +24,34 @@ export default class Kids {
const watch_endpoint = new NavigationEndpoint({ watchEndpoint: payload });
const watch_next_endpoint = new NavigationEndpoint({ watchNextEndpoint: payload });
const session = this.#session;
const extra_payload: Record<string, any> = {
playbackContext: {
contentPlaybackContext: {
vis: 0,
splay: false,
lactMilliseconds: '-1',
signatureTimestamp: this.#session.player?.sts
signatureTimestamp: session.player?.sts
}
},
client: 'YTKIDS'
};
if (this.#session.po_token) {
if (session.po_token) {
extra_payload.serviceIntegrityDimensions = {
poToken: this.#session.po_token
poToken: session.po_token
};
}
const watch_response = watch_endpoint.call(this.#session.actions, extra_payload);
const watch_response = watch_endpoint.call(session.actions, extra_payload);
const watch_next_response = watch_next_endpoint.call(this.#session.actions, { client: 'YTKIDS' });
const watch_next_response = watch_next_endpoint.call(session.actions, { client: 'YTKIDS' });
const response = await Promise.all([ watch_response, watch_next_response ]);
const cpn = generateRandomString(16);
return new VideoInfo(response, this.#session.actions, cpn);
return new VideoInfo(response, session.actions, cpn);
}
async getChannel(channel_id: string): Promise<Channel> {
@@ -71,7 +73,9 @@ export default class Kids {
* @returns A list of API responses.
*/
async blockChannel(channel_id: string): Promise<ApiResponse[]> {
if (!this.#session.logged_in)
const session = this.#session;
if (!session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const kids_blocklist_picker_command = new NavigationEndpoint({
@@ -82,7 +86,7 @@ export default class Kids {
}
});
const response = await kids_blocklist_picker_command.call(this.#session.actions, { client: 'YTKIDS' });
const response = await kids_blocklist_picker_command.call(session.actions, { client: 'YTKIDS' });
const popup = response.data.command.confirmDialogEndpoint;
const popup_fragment = { contents: popup.content, engagementPanels: [] };
const kid_picker = Parser.parseResponse(popup_fragment);
@@ -96,7 +100,7 @@ export default class Kids {
for (const kid of kids) {
if (!kid.block_button?.is_toggled) {
kid.setActions(this.#session.actions);
kid.setActions(session.actions);
// Block channel and add to the response list.
responses.push(await kid.blockChannel());
}

View File

@@ -45,7 +45,9 @@ export default class Studio {
* ```
*/
async updateVideoMetadata(video_id: string, metadata: UpdateVideoMetadataOptions): Promise<ApiResponse> {
if (!this.#session.logged_in)
const session = this.#session;
if (!session.logged_in)
throw new InnertubeError('You must be signed in to perform this operation.');
const payload: MetadataUpdateRequest = {
@@ -55,18 +57,18 @@ export default class Studio {
clientName: parseInt(Constants.CLIENT_NAME_IDS.ANDROID),
clientVersion: Constants.CLIENTS.ANDROID.VERSION,
androidSdkVersion: Constants.CLIENTS.ANDROID.SDK_VERSION,
visitorData: this.#session.context.client.visitorData,
visitorData: session.context.client.visitorData,
osVersion: '13',
acceptLanguage: this.#session.context.client.hl,
acceptRegion: this.#session.context.client.gl,
acceptLanguage: session.context.client.hl,
acceptRegion: session.context.client.gl,
deviceMake: 'Google',
deviceModel: 'sdk_gphone64_x86_64',
screenHeightPoints: 840,
screenWidthPoints: 432,
configInfo: {
appInstallData: this.#session.context.client.configInfo?.appInstallData
appInstallData: session.context.client.configInfo?.appInstallData
},
timeZone: this.#session.context.client.timeZone,
timeZone: session.context.client.timeZone,
chipset: 'qcom;taro'
},
activePlayers: []
@@ -131,7 +133,7 @@ export default class Studio {
const writer = MetadataUpdateRequest.encode(payload);
return await this.#session.actions.execute('/video_manager/metadata_update', {
return await session.actions.execute('/video_manager/metadata_update', {
protobuf: true,
serialized_data: writer.finish()
});

View File

@@ -29,10 +29,13 @@ import TwoColumnSearchResults from '../../parser/classes/TwoColumnSearchResults.
import WatchCardCompactVideo from '../../parser/classes/WatchCardCompactVideo.js';
import type { Actions, ApiResponse } from '../index.js';
import type { Memo, ObservedArray, SuperParsedResult, YTNode } from '../../parser/helpers.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 SectionList from '../../parser/classes/SectionList.js';
import type SecondarySearchContainer from '../../parser/classes/SecondarySearchContainer.js';
import type BrowseFeedActions from '../../parser/classes/BrowseFeedActions.js';
import type ProfileColumn from '../../parser/classes/ProfileColumn.js';
export default class Feed<T extends IParsedResponse = IParsedResponse> {
readonly #page: T;
@@ -163,14 +166,14 @@ export default class Feed<T extends IParsedResponse = IParsedResponse> {
/**
* Returns secondary contents from the page.
*/
get secondary_contents(): SuperParsedResult<YTNode> | undefined {
get secondary_contents(): SectionList | SecondarySearchContainer | BrowseFeedActions | ProfileColumn | null {
if (!this.#page.contents?.is_node)
return undefined;
return null;
const node = this.#page.contents?.item();
if (!node.is(TwoColumnBrowseResults, TwoColumnSearchResults))
return undefined;
return null;
return node.secondary_contents;
}

View File

@@ -2,11 +2,13 @@ import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
export type AlertType = 'UNKNOWN' | 'WARNING' | 'ERROR' | 'SUCCESS' | 'INFO';
export default class Alert extends YTNode {
static type = 'Alert';
text: Text;
alert_type: string;
alert_type: AlertType;
constructor(data: RawNode) {
super();

View File

@@ -1,5 +1,5 @@
import { YTNode } from '../helpers.js';
import type { RawNode } from '../types/RawResponse.js';
import type { RawNode } from '../types/index.js';
export default class BadgeView extends YTNode {
text: string;

View File

@@ -1,13 +1,19 @@
import { Parser, type RawNode } from '../index.js';
import { type ObservedArray, YTNode } from '../helpers.js';
import SubFeedSelector from './SubFeedSelector.js';
import EomSettingsDisclaimer from './EomSettingsDisclaimer.js';
import ToggleButton from './ToggleButton.js';
import CompactLink from './CompactLink.js';
import SearchBox from './SearchBox.js';
import Button from './Button.js';
export default class BrowseFeedActions extends YTNode {
static type = 'BrowseFeedActions';
contents: ObservedArray<YTNode>;
public contents: ObservedArray<SubFeedSelector | EomSettingsDisclaimer | ToggleButton | CompactLink | SearchBox | Button>;
constructor(data: RawNode) {
super();
this.contents = Parser.parseArray(data.contents);
this.contents = Parser.parseArray(data.contents, [ SubFeedSelector, EomSettingsDisclaimer, ToggleButton, CompactLink, SearchBox, Button ]);
}
}

View File

@@ -1,28 +1,124 @@
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Thumbnail from './misc/Thumbnail.js';
export default class ButtonView extends YTNode {
static type = 'ButtonView';
icon_name: string;
title: string;
accessibility_text: string;
style: string;
is_full_width: boolean;
button_type: string;
button_size: string;
on_tap: NavigationEndpoint;
public secondary_icon_image?: Thumbnail[];
public icon_name?: string;
public enable_icon_button?: boolean;
public tooltip?: string;
public icon_image_flip_for_rtl?: boolean;
public button_size?: 'BUTTON_VIEW_MODEL_SIZE_UNKNOWN' | 'BUTTON_VIEW_MODEL_SIZE_DEFAULT' | 'BUTTON_VIEW_MODEL_SIZE_COMPACT' | 'BUTTON_VIEW_MODEL_SIZE_XSMALL' | 'BUTTON_VIEW_MODEL_SIZE_LARGE' | 'BUTTON_VIEW_MODEL_SIZE_XLARGE' | 'BUTTON_VIEW_MODEL_SIZE_XXLARGE';
public icon_position?: 'BUTTON_VIEW_MODEL_ICON_POSITION_UNKNOWN' | 'BUTTON_VIEW_MODEL_ICON_POSITION_TRAILING' | 'BUTTON_VIEW_MODEL_ICON_POSITION_LEADING' | 'BUTTON_VIEW_MODEL_ICON_POSITION_ABOVE' | 'BUTTON_VIEW_MODEL_ICON_POSITION_LEADING_TRAILING';
public is_full_width?: boolean;
public state?: 'BUTTON_VIEW_MODEL_STATE_UNKNOWN' | 'BUTTON_VIEW_MODEL_STATE_ACTIVE' | 'BUTTON_VIEW_MODEL_STATE_INACTIVE' | 'BUTTON_VIEW_MODEL_STATE_DISABLED';
public on_disabled_tap?: NavigationEndpoint;
public custom_border_color?: number;
public on_tap?: NavigationEndpoint;
public style?: 'BUTTON_VIEW_MODEL_STYLE_UNKNOWN' | 'BUTTON_VIEW_MODEL_STYLE_CTA' | 'BUTTON_VIEW_MODEL_STYLE_BRAND' | 'BUTTON_VIEW_MODEL_STYLE_ADS_CTA' | 'BUTTON_VIEW_MODEL_STYLE_OVERLAY' | 'BUTTON_VIEW_MODEL_STYLE_CTA_THEMED' | 'BUTTON_VIEW_MODEL_STYLE_BLACK_CTA' | 'BUTTON_VIEW_MODEL_STYLE_CUSTOM' | 'BUTTON_VIEW_MODEL_STYLE_MONO' | 'BUTTON_VIEW_MODEL_STYLE_OVERLAY_DARK' | 'BUTTON_VIEW_MODEL_STYLE_CTA_OVERLAY' | 'BUTTON_VIEW_MODEL_STYLE_BRAND_AI' | 'BUTTON_VIEW_MODEL_STYLE_YT_GRADIENT' | 'BUTTON_VIEW_MODEL_STYLE_BRAND_GRADIENT';
public icon_image?: object;
public custom_dark_theme_border_color?: number;
public title?: string;
public target_id?: string;
public enable_full_width_margins?: boolean;
public custom_font_color?: number;
public button_type?: 'BUTTON_VIEW_MODEL_TYPE_UNKNOWN' | 'BUTTON_VIEW_MODEL_TYPE_FILLED' | 'BUTTON_VIEW_MODEL_TYPE_OUTLINE' | 'BUTTON_VIEW_MODEL_TYPE_TEXT' | 'BUTTON_VIEW_MODEL_TYPE_TONAL';
public enabled?: boolean;
public accessibility_id?: string;
public custom_background_color?: number;
public on_long_press?: NavigationEndpoint;
public title_formatted?: object;
public on_visible?: object;
public icon_trailing?: boolean;
public accessibility_text?: string;
constructor(data: RawNode) {
super();
this.icon_name = data.iconName;
this.title = data.title;
this.accessibility_text = data.accessibilityText;
this.style = data.style;
this.is_full_width = data.isFullWidth;
this.button_type = data.type;
this.button_size = data.buttonSize;
this.on_tap = new NavigationEndpoint(data.onTap);
if ('secondaryIconImage' in data)
this.secondary_icon_image = Thumbnail.fromResponse(data.secondaryIconImage);
if ('iconName' in data)
this.icon_name = data.iconName;
if ('enableIconButton' in data)
this.enable_icon_button = data.enableIconButton;
if ('tooltip' in data)
this.tooltip = data.tooltip;
if ('iconImageFlipForRtl' in data)
this.icon_image_flip_for_rtl = data.iconImageFlipForRtl;
if ('buttonSize' in data)
this.button_size = data.buttonSize;
if ('iconPosition' in data)
this.icon_position = data.iconPosition;
if ('isFullWidth' in data)
this.is_full_width = data.isFullWidth;
if ('state' in data)
this.state = data.state;
if ('onDisabledTap' in data)
this.on_disabled_tap = new NavigationEndpoint(data.onDisabledTap);
if ('customBorderColor' in data)
this.custom_border_color = data.customBorderColor;
if ('onTap' in data)
this.on_tap = new NavigationEndpoint(data.onTap);
if ('style' in data)
this.style = data.style;
if ('iconImage' in data)
this.icon_image = data.iconImage;
if ('customDarkThemeBorderColor' in data)
this.custom_dark_theme_border_color = data.customDarkThemeBorderColor;
if ('title' in data)
this.title = data.title;
if ('targetId' in data)
this.target_id = data.targetId;
if ('enableFullWidthMargins' in data)
this.enable_full_width_margins = data.enableFullWidthMargins;
if ('customFontColor' in data)
this.custom_font_color = data.customFontColor;
if ('buttonType' in data)
this.button_type = data.buttonType;
if ('enabled' in data)
this.enabled = data.enabled;
if ('accessibilityId' in data)
this.accessibility_id = data.accessibilityId;
if ('customBackgroundColor' in data)
this.custom_background_color = data.customBackgroundColor;
if ('onLongPress' in data)
this.on_long_press = new NavigationEndpoint(data.onLongPress);
if ('titleFormatted' in data)
this.title_formatted = data.titleFormatted;
if ('onVisible' in data)
this.on_visible = data.onVisible;
if ('iconTrailing' in data)
this.icon_trailing = data.iconTrailing;
if ('accessibilityText' in data)
this.accessibility_text = data.accessibilityText;
}
}

View File

@@ -1,5 +1,5 @@
import { timeToSeconds } from '../../utils/Utils.js';
import { YTNode, type ObservedArray } from '../helpers.js';
import { type ObservedArray, YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Menu from './menus/Menu.js';
import MetadataBadge from './MetadataBadge.js';
@@ -7,53 +7,90 @@ import Author from './misc/Author.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import ThumbnailOverlayTimeStatus from './ThumbnailOverlayTimeStatus.js';
export default class CompactVideo extends YTNode {
static type = 'CompactVideo';
id: string;
thumbnails: Thumbnail[];
rich_thumbnail?: YTNode;
title: Text;
author: Author;
view_count: Text;
short_view_count: Text;
published: Text;
badges: MetadataBadge[];
duration: {
text: string;
seconds: number;
};
thumbnail_overlays: ObservedArray<YTNode>;
endpoint: NavigationEndpoint;
menu: Menu | null;
public video_id: string;
public thumbnails: Thumbnail[];
public rich_thumbnail?: YTNode;
public title: Text;
public author: Author;
public view_count?: Text;
public short_view_count?: Text;
public short_byline_text?: Text;
public long_byline_text?: Text;
public published?: Text;
public badges: MetadataBadge[];
public thumbnail_overlays: ObservedArray<YTNode>;
public endpoint?: NavigationEndpoint;
public menu: Menu | null;
public length_text?: Text;
public is_watched: boolean;
public service_endpoints?: NavigationEndpoint[];
public service_endpoint?: NavigationEndpoint;
public style?: 'COMPACT_VIDEO_STYLE_TYPE_UNKNOWN' | 'COMPACT_VIDEO_STYLE_TYPE_NORMAL' | 'COMPACT_VIDEO_STYLE_TYPE_PROMINENT_THUMBNAIL' | 'COMPACT_VIDEO_STYLE_TYPE_HERO';
constructor(data: RawNode) {
super();
this.id = data.videoId;
this.thumbnails = Thumbnail.fromResponse(data.thumbnail) || null;
if (Reflect.has(data, 'richThumbnail')) {
this.rich_thumbnail = Parser.parseItem(data.richThumbnail);
}
this.video_id = data.videoId;
this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
this.title = new Text(data.title);
this.author = new Author(data.longBylineText, data.ownerBadges, data.channelThumbnail);
this.view_count = new Text(data.viewCountText);
this.short_view_count = new Text(data.shortViewCountText);
this.published = new Text(data.publishedTimeText);
this.is_watched = !!data.isWatched;
this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);
this.menu = Parser.parseItem(data.menu, Menu);
this.badges = Parser.parseArray(data.badges, MetadataBadge);
this.duration = {
text: new Text(data.lengthText).toString(),
seconds: timeToSeconds(new Text(data.lengthText).toString())
};
if ('publishedTimeText' in data)
this.published = new Text(data.publishedTimeText);
if ('shortBylineText' in data)
this.view_count = new Text(data.viewCountText);
this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.menu = Parser.parseItem(data.menu, Menu);
if ('shortViewCountText' in data)
this.short_view_count = new Text(data.shortViewCountText);
if ('richThumbnail' in data)
this.rich_thumbnail = Parser.parseItem(data.richThumbnail);
if ('shortBylineText' in data)
this.short_byline_text = new Text(data.shortBylineText);
if ('longBylineText' in data)
this.long_byline_text = new Text(data.longBylineText);
if ('lengthText' in data)
this.length_text = new Text(data.lengthText);
if ('serviceEndpoints' in data)
this.service_endpoints = data.serviceEndpoints.map((endpoint: RawNode) => new NavigationEndpoint(endpoint));
if ('serviceEndpoint' in data)
this.service_endpoint = new NavigationEndpoint(data.serviceEndpoint);
if ('navigationEndpoint' in data)
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
if ('style' in data)
this.style = data.style;
}
/**
* @deprecated Use {@linkcode video_id} instead.
*/
get id(): string {
return this.video_id;
}
get duration() {
const overlay_time_status = this.thumbnail_overlays.firstOfType(ThumbnailOverlayTimeStatus);
const length_text = this.length_text?.toString() || overlay_time_status?.text.toString();
return {
text: length_text,
seconds: length_text ? timeToSeconds(length_text) : 0
};
}
get best_thumbnail() {

View File

@@ -1,4 +1,4 @@
import { YTNode, type ObservedArray } from '../helpers.js';
import { type ObservedArray, YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Menu from './menus/Menu.js';
@@ -9,28 +9,28 @@ import Thumbnail from './misc/Thumbnail.js';
export default class GridVideo extends YTNode {
static type = 'GridVideo';
id: string;
title: Text;
thumbnails: Thumbnail[];
thumbnail_overlays: ObservedArray<YTNode>;
rich_thumbnail: YTNode;
published: Text;
duration: Text | null;
author: Author;
views: Text;
short_view_count: Text;
endpoint: NavigationEndpoint;
menu: Menu | null;
buttons?: ObservedArray<YTNode>;
upcoming?: Date;
upcoming_text?: Text;
is_reminder_set?: boolean;
public video_id: string;
public title: Text;
public thumbnails: Thumbnail[];
public thumbnail_overlays: ObservedArray<YTNode>;
public rich_thumbnail: YTNode;
public published: Text;
public duration: Text | null;
public author: Author;
public views: Text;
public short_view_count: Text;
public endpoint: NavigationEndpoint;
public menu: Menu | null;
public buttons?: ObservedArray<YTNode>;
public upcoming?: Date;
public upcoming_text?: Text;
public is_reminder_set?: boolean;
constructor(data: RawNode) {
super();
const length_alt = data.thumbnailOverlays.find((overlay: RawNode) => overlay.hasOwnProperty('thumbnailOverlayTimeStatusRenderer'))?.thumbnailOverlayTimeStatusRenderer;
this.id = data.videoId;
this.video_id = data.videoId;
this.title = new Text(data.title);
this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);
@@ -54,6 +54,13 @@ export default class GridVideo extends YTNode {
}
}
/**
* @deprecated Use {@linkcode video_id} instead.
*/
get id(): string {
return this.video_id;
}
get is_upcoming(): boolean {
return Boolean(this.upcoming && this.upcoming > new Date());
}

View File

@@ -11,7 +11,7 @@ export default class LockupView extends YTNode {
public content_image: CollectionThumbnailView | ThumbnailView | null;
public metadata: LockupMetadataView | null;
public content_id: string;
public content_type: 'VIDEO' | 'MOVIE' | 'CHANNEL' | 'CLIP' | 'SOURCE' | 'PLAYLIST' | 'ALBUM' | 'PODCAST' | 'SHOPPING_COLLECTION' | 'SHORT' | 'GAME' | 'PRODUCT';
public content_type: 'UNSPECIFIED' | 'VIDEO' | 'PLAYLIST' | 'SHORT' | 'CHANNEL' | 'ALBUM' | 'PRODUCT' | 'GAME' | 'CLIP' | 'PODCAST' | 'SOURCE' | 'SHOPPING_COLLECTION' | 'MOVIE';
public renderer_context: RendererContext;
constructor(data: RawNode) {

View File

@@ -15,15 +15,16 @@ import NavigationEndpoint from './NavigationEndpoint.js';
import Menu from './menus/Menu.js';
import Text from './misc/Text.js';
interface PlaylistItemData {
video_id: string;
playlist_set_video_id: string;
}
export default class MusicResponsiveListItem extends YTNode {
static type = 'MusicResponsiveListItem';
flex_columns: ObservedArray<MusicResponsiveListItemFlexColumn>;
fixed_columns: ObservedArray<MusicResponsiveListItemFixedColumn>;
#playlist_item_data: {
video_id: string;
playlist_set_video_id: string;
};
endpoint?: NavigationEndpoint;
item_type: 'album' | 'playlist' | 'artist' | 'library_artist' | 'non_music_track' | 'video' | 'song' | 'endpoint' | 'unknown' | 'podcast_show' | undefined;
@@ -78,7 +79,7 @@ export default class MusicResponsiveListItem extends YTNode {
this.flex_columns = Parser.parseArray(data.flexColumns, MusicResponsiveListItemFlexColumn);
this.fixed_columns = Parser.parseArray(data.fixedColumns, MusicResponsiveListItemFixedColumn);
this.#playlist_item_data = {
const playlist_item_data: PlaylistItemData = {
video_id: data?.playlistItemData?.videoId || null,
playlist_set_video_id: data?.playlistItemData?.playlistSetVideoId || null
};
@@ -119,7 +120,7 @@ export default class MusicResponsiveListItem extends YTNode {
break;
case 'MUSIC_PAGE_TYPE_NON_MUSIC_AUDIO_TRACK_PAGE':
this.item_type = 'non_music_track';
this.#parseNonMusicTrack();
this.#parseNonMusicTrack(playlist_item_data);
break;
case 'MUSIC_PAGE_TYPE_PODCAST_SHOW_DETAIL_PAGE':
this.item_type = 'podcast_show';
@@ -127,7 +128,7 @@ export default class MusicResponsiveListItem extends YTNode {
break;
default:
if (this.flex_columns[1]) {
this.#parseVideoOrSong();
this.#parseVideoOrSong(playlist_item_data);
} else {
this.#parseOther();
}
@@ -164,25 +165,25 @@ export default class MusicResponsiveListItem extends YTNode {
}
}
#parseVideoOrSong() {
#parseVideoOrSong(playlist_item_data: PlaylistItemData) {
const music_video_type = (this.flex_columns.at(0)?.title.runs?.at(0) as TextRun)?.endpoint?.payload?.watchEndpointMusicSupportedConfigs?.watchEndpointMusicConfig?.musicVideoType;
switch (music_video_type) {
case 'MUSIC_VIDEO_TYPE_UGC':
case 'MUSIC_VIDEO_TYPE_OMV':
this.item_type = 'video';
this.#parseVideo();
this.#parseVideo(playlist_item_data);
break;
case 'MUSIC_VIDEO_TYPE_ATV':
this.item_type = 'song';
this.#parseSong();
this.#parseSong(playlist_item_data);
break;
default:
this.#parseOther();
}
}
#parseSong() {
this.id = this.#playlist_item_data.video_id || this.endpoint?.payload?.videoId;
#parseSong(playlist_item_data: PlaylistItemData) {
this.id = playlist_item_data.video_id || this.endpoint?.payload?.videoId;
this.title = this.flex_columns[0].title.toString();
const duration_text = this.flex_columns.at(1)?.title.runs?.find(
@@ -228,8 +229,8 @@ export default class MusicResponsiveListItem extends YTNode {
}
}
#parseVideo() {
this.id = this.#playlist_item_data.video_id;
#parseVideo(playlist_item_data: PlaylistItemData) {
this.id = playlist_item_data.video_id;
this.title = this.flex_columns[0].title.toString();
this.views = this.flex_columns.at(1)?.title.runs?.find((run) => run.text.match(/(.*?) views/))?.toString();
@@ -273,8 +274,8 @@ export default class MusicResponsiveListItem extends YTNode {
this.song_count = this.subtitle?.runs?.find((run) => (/^\d+(,\d+)? songs?$/i).test(run.text))?.text || '';
}
#parseNonMusicTrack() {
this.id = this.#playlist_item_data.video_id || this.endpoint?.payload?.videoId;
#parseNonMusicTrack(playlist_item_data: PlaylistItemData) {
this.id = playlist_item_data.video_id || this.endpoint?.payload?.videoId;
this.title = this.flex_columns[0].title.toString();
}

View File

@@ -4,7 +4,7 @@ import { Parser, type RawNode } from '../index.js';
export default class ProfileColumn extends YTNode {
static type = 'ProfileColumn';
items: ObservedArray<YTNode>;
public items: ObservedArray<YTNode>;
constructor(data: RawNode) {
super();

View File

@@ -1,13 +1,15 @@
import { Parser, type RawNode } from '../index.js';
import { type ObservedArray, YTNode } from '../helpers.js';
import UniversalWatchCard from './UniversalWatchCard.js';
export default class SecondarySearchContainer extends YTNode {
static type = 'SecondarySearchContainer';
contents: ObservedArray<YTNode>;
public target_id?: string;
public contents: ObservedArray<UniversalWatchCard>;
constructor(data: RawNode) {
super();
this.contents = Parser.parseArray(data.contents);
this.contents = Parser.parseArray(data.contents, [ UniversalWatchCard ]);
}
}

View File

@@ -34,11 +34,12 @@ export default class SegmentedLikeDislikeButtonView extends YTNode {
if (toggle_button.default_button) {
this.short_like_count = toggle_button.default_button.title;
this.like_count = parseInt(toggle_button.default_button.accessibility_text.replace(/\D/g, ''));
if (toggle_button.default_button.accessibility_text)
this.like_count = parseInt(toggle_button.default_button.accessibility_text.replace(/\D/g, ''));
} else if (toggle_button.toggled_button) {
this.short_like_count = toggle_button.toggled_button.title;
this.like_count = parseInt(toggle_button.toggled_button.accessibility_text.replace(/\D/g, ''));
if (toggle_button.toggled_button.accessibility_text)
this.like_count = parseInt(toggle_button.toggled_button.accessibility_text.replace(/\D/g, ''));
}
}

View File

@@ -1,15 +1,21 @@
import { YTNode, type SuperParsedResult } from '../helpers.js';
import type { ObservedArray } from '../helpers.js';
import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import SectionList from './SectionList.js';
import BrowseFeedActions from './BrowseFeedActions.js';
import ProfileColumn from './ProfileColumn.js';
import Tab from './Tab.js';
import ExpandableTab from './ExpandableTab.js';
export default class TwoColumnBrowseResults extends YTNode {
static type = 'TwoColumnBrowseResults';
tabs: SuperParsedResult<YTNode>;
secondary_contents: SuperParsedResult<YTNode>;
public tabs: ObservedArray<Tab | ExpandableTab>;
public secondary_contents: SectionList | BrowseFeedActions | ProfileColumn | null;
constructor(data: RawNode) {
super();
this.tabs = Parser.parse(data.tabs);
this.secondary_contents = Parser.parse(data.secondaryContents);
this.tabs = Parser.parseArray(data.tabs, [ Tab, ExpandableTab ]);
this.secondary_contents = Parser.parseItem(data.secondaryContents, [ SectionList, BrowseFeedActions, ProfileColumn ]);
}
}

View File

@@ -1,15 +1,25 @@
import { YTNode, type SuperParsedResult } from '../helpers.js';
import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import SecondarySearchContainer from './SecondarySearchContainer.js';
import RichGrid from './RichGrid.js';
import SectionList from './SectionList.js';
export default class TwoColumnSearchResults extends YTNode {
static type = 'TwoColumnSearchResults';
primary_contents: SuperParsedResult<YTNode>;
secondary_contents: SuperParsedResult<YTNode>;
public header: YTNode | null;
public primary_contents: RichGrid | SectionList | null;
public secondary_contents: SecondarySearchContainer | null;
public target_id?: string;
constructor(data: RawNode) {
super();
this.primary_contents = Parser.parse(data.primaryContents);
this.secondary_contents = Parser.parse(data.secondaryContents);
this.header = Parser.parseItem(data.header);
this.primary_contents = Parser.parseItem(data.primaryContents, [ RichGrid, SectionList ]);
this.secondary_contents = Parser.parseItem(data.secondaryContents, [ SecondarySearchContainer ]);
if ('targetId' in data) {
this.target_id = data.targetId;
}
}
}

View File

@@ -1,5 +1,5 @@
import { timeToSeconds } from '../../utils/Utils.js';
import { YTNode, type ObservedArray } from '../helpers.js';
import { type ObservedArray, YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import ExpandableMetadata from './ExpandableMetadata.js';
import MetadataBadge from './MetadataBadge.js';
@@ -13,96 +13,112 @@ import Thumbnail from './misc/Thumbnail.js';
export default class Video extends YTNode {
static type = 'Video';
id: string;
title: Text;
description_snippet?: Text;
snippets?: {
text: Text;
hover_text: Text;
}[];
expandable_metadata: ExpandableMetadata | null;
thumbnails: Thumbnail[];
thumbnail_overlays: ObservedArray<YTNode>;
rich_thumbnail?: YTNode;
author: Author;
badges: MetadataBadge[];
endpoint: NavigationEndpoint;
published: Text;
view_count: Text;
short_view_count: Text;
upcoming?: Date;
duration: {
text: string;
seconds: number;
};
show_action_menu: boolean;
is_watched: boolean;
menu: Menu | null;
byline_text?: Text;
search_video_result_entity_key?: string;
public video_id: string;
public title: Text;
public untranslated_title?: Text;
public description_snippet?: Text;
public snippets?: { text: Text; hover_text: Text; }[];
public expandable_metadata: ExpandableMetadata | null;
public additional_metadatas?: Text[];
public thumbnails: Thumbnail[];
public thumbnail_overlays: ObservedArray<YTNode>;
public rich_thumbnail?: YTNode;
public author: Author;
public badges: MetadataBadge[];
public endpoint?: NavigationEndpoint;
public published?: Text;
public view_count?: Text;
public short_view_count?: Text;
public upcoming?: Date;
public length_text?: Text;
public show_action_menu: boolean;
public is_watched: boolean;
public menu: Menu | null;
public byline_text?: Text;
public search_video_result_entity_key?: string;
public service_endpoints?: NavigationEndpoint[];
public service_endpoint?: NavigationEndpoint;
public style?: 'VIDEO_STYLE_TYPE_UNKNOWN' | 'VIDEO_STYLE_TYPE_NORMAL' | 'VIDEO_STYLE_TYPE_POST' | 'VIDEO_STYLE_TYPE_SUB' | 'VIDEO_STYLE_TYPE_LIVE_POST' | 'VIDEO_STYLE_TYPE_FULL_BLEED_ISOLATED' | 'VIDEO_STYLE_TYPE_WITH_EXPANDED_METADATA';
constructor(data: RawNode) {
super();
const overlay_time_status = data.thumbnailOverlays
.find((overlay: any) => overlay.thumbnailOverlayTimeStatusRenderer)
?.thumbnailOverlayTimeStatusRenderer.text || 'N/A';
this.id = data.videoId;
this.title = new Text(data.title);
this.video_id = data.videoId;
this.expandable_metadata = Parser.parseItem(data.expandableMetadata, ExpandableMetadata);
if (Reflect.has(data, 'descriptionSnippet')) {
if ('untranslatedTitle' in data)
this.untranslated_title = new Text(data.untranslatedTitle);
if ('descriptionSnippet' in data)
this.description_snippet = new Text(data.descriptionSnippet);
}
if (Reflect.has(data, 'detailedMetadataSnippets')) {
if ('detailedMetadataSnippets' in data) {
this.snippets = data.detailedMetadataSnippets.map((snippet: RawNode) => ({
text: new Text(snippet.snippetText),
hover_text: new Text(snippet.snippetHoverText)
}));
}
this.expandable_metadata = Parser.parseItem(data.expandableMetadata, ExpandableMetadata);
if ('additionalMetadatas' in data)
this.additional_metadatas = data.additionalMetadatas.map((meta: RawNode) => new Text(meta));
this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);
if (Reflect.has(data, 'richThumbnail')) {
if ('richThumbnail' in data)
this.rich_thumbnail = Parser.parseItem(data.richThumbnail);
}
this.author = new Author(data.ownerText, data.ownerBadges, data.channelThumbnailSupportedRenderers?.channelThumbnailWithLinkRenderer?.thumbnail);
this.badges = Parser.parseArray(data.badges, MetadataBadge);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.published = new Text(data.publishedTimeText);
this.view_count = new Text(data.viewCountText);
this.short_view_count = new Text(data.shortViewCountText);
if (Reflect.has(data, 'upcomingEventData')) {
if ('navigationEndpoint' in data)
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
if ('publishedTimeText' in data)
this.published = new Text(data.publishedTimeText);
if ('viewCountText' in data)
this.view_count = new Text(data.viewCountText);
if ('shortViewCountText' in data)
this.short_view_count = new Text(data.shortViewCountText);
if ('upcomingEventData' in data)
this.upcoming = new Date(Number(`${data.upcomingEventData.startTime}000`));
}
this.duration = {
text: data.lengthText ? new Text(data.lengthText).toString() : new Text(overlay_time_status).toString(),
seconds: timeToSeconds(data.lengthText ? new Text(data.lengthText).toString() : new Text(overlay_time_status).toString())
};
this.show_action_menu = !!data.showActionMenu;
this.is_watched = !!data.isWatched;
this.menu = Parser.parseItem(data.menu, Menu);
if (Reflect.has(data, 'searchVideoResultEntityKey')) {
if ('searchVideoResultEntityKey' in data)
this.search_video_result_entity_key = data.searchVideoResultEntityKey;
}
if (Reflect.has(data, 'bylineText')) {
if ('bylineText' in data)
this.byline_text = new Text(data.bylineText);
}
if ('lengthText' in data)
this.length_text = new Text(data.lengthText);
if ('serviceEndpoints' in data)
this.service_endpoints = data.serviceEndpoints.map((endpoint: RawNode) => new NavigationEndpoint(endpoint));
if ('serviceEndpoint' in data)
this.service_endpoint = new NavigationEndpoint(data.serviceEndpoint);
if ('style' in data)
this.style = data.style;
}
/**
* @deprecated Use {@linkcode video_id} instead.
*/
get id(): string {
return this.video_id;
}
get description(): string {
if (this.snippets) {
if (this.snippets)
return this.snippets.map((snip) => snip.text.toString()).join('');
}
return this.description_snippet?.toString() || '';
}
@@ -132,4 +148,13 @@ export default class Video extends YTNode {
get best_thumbnail(): Thumbnail | undefined {
return this.thumbnails[0];
}
get duration() {
const overlay_time_status = this.thumbnail_overlays.firstOfType(ThumbnailOverlayTimeStatus);
const length_text = this.length_text?.toString() || overlay_time_status?.text.toString();
return {
text: length_text,
seconds: length_text ? timeToSeconds(length_text) : 0
};
}
}

View File

@@ -2,20 +2,20 @@ import Text from './misc/Text.js';
import Author from './misc/Author.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import SubscriptionButton from './misc/SubscriptionButton.js';
export default class VideoOwner extends YTNode {
static type = 'VideoOwner';
subscription_button;
subscriber_count: Text;
author: Author;
public subscription_button?: SubscriptionButton;
public subscriber_count: Text;
public author: Author;
constructor(data: RawNode) {
super();
// TODO: check this
this.subscription_button = data.subscriptionButton;
if ('subscriptionButton' in data)
this.subscription_button = new SubscriptionButton(data.subscriptionButton);
this.subscriber_count = new Text(data.subscriberCountText);
this.author = new Author({
...data.title,
navigationEndpoint: data.navigationEndpoint

View File

@@ -1,5 +1,5 @@
import { Parser, type RawNode } from '../index.js';
import { YTNode, type ObservedArray } from '../helpers.js';
import { type ObservedArray, YTNode } from '../helpers.js';
import Text from './misc/Text.js';
import Menu from './menus/Menu.js';
@@ -11,6 +11,7 @@ export default class VideoPrimaryInfo extends YTNode {
public title: Text;
public super_title_link?: Text;
public station_name?: Text;
public view_count: VideoViewCount | null;
public badges: ObservedArray<MetadataBadge>;
public published: Text;
@@ -21,9 +22,12 @@ export default class VideoPrimaryInfo extends YTNode {
super();
this.title = new Text(data.title);
if (Reflect.has(data, 'superTitleLink'))
if ('superTitleLink' in data)
this.super_title_link = new Text(data.superTitleLink);
if ('stationName' in data)
this.station_name = new Text(data.stationName);
this.view_count = Parser.parseItem(data.viewCount, VideoViewCount);
this.badges = Parser.parseArray(data.badges, MetadataBadge);
this.published = new Text(data.dateText);

View File

@@ -9,23 +9,26 @@ import { YTNode } from '../helpers.js';
export default class VideoSecondaryInfo extends YTNode {
static type = 'VideoSecondaryInfo';
owner: VideoOwner | null;
description: Text;
subscribe_button: SubscribeButton | Button | null;
metadata: MetadataRowContainer | null;
show_more_text: Text;
show_less_text: Text;
default_expanded: string;
description_collapsed_lines: string;
public owner: VideoOwner | null;
public description: Text;
public description_placeholder?: Text;
public subscribe_button: SubscribeButton | Button | null;
public metadata: MetadataRowContainer | null;
public show_more_text: Text;
public show_less_text: Text;
public default_expanded: string;
public description_collapsed_lines: string;
constructor(data: RawNode) {
super();
this.owner = Parser.parseItem(data.owner, VideoOwner);
this.description = new Text(data.description);
if (Reflect.has(data, 'attributedDescription')) {
if ('attributedDescription' in data)
this.description = Text.fromAttributed(data.attributedDescription);
}
if ('descriptionPlaceholder' in data)
this.description_placeholder = new Text(data.descriptionPlaceholder);
this.subscribe_button = Parser.parseItem(data.subscribeButton, [ SubscribeButton, Button ]);
this.metadata = Parser.parseItem(data.metadataRowContainer, MetadataRowContainer);

View File

@@ -0,0 +1,9 @@
import type { RawNode } from '../../types/index.js';
export default class AccessibilityContext {
public label: string;
constructor(data: RawNode) {
this.label = data.label;
}
}

View File

@@ -0,0 +1,47 @@
import type { RawNode } from '../../types/index.js';
import NavigationEndpoint from '../NavigationEndpoint.js';
export default class CommandContext {
public on_focus?: NavigationEndpoint;
public on_hidden?: NavigationEndpoint;
public on_touch_end?: NavigationEndpoint;
public on_touch_move?: NavigationEndpoint;
public on_long_press?: NavigationEndpoint;
public on_tap?: NavigationEndpoint;
public on_touch_start?: NavigationEndpoint;
public on_visible?: NavigationEndpoint;
public on_first_visible?: NavigationEndpoint;
public on_hover?: NavigationEndpoint;
constructor(data: RawNode) {
if ('onFocus' in data)
this.on_focus = new NavigationEndpoint(data.onFocus);
if ('onHidden' in data)
this.on_hidden = new NavigationEndpoint(data.onHidden);
if ('onTouchEnd' in data)
this.on_touch_end = new NavigationEndpoint(data.onTouchEnd);
if ('onTouchMove' in data)
this.on_touch_move = new NavigationEndpoint(data.onTouchMove);
if ('onLongPress' in data)
this.on_long_press = new NavigationEndpoint(data.onLongPress);
if ('onTap' in data)
this.on_tap = new NavigationEndpoint(data.onTap);
if ('onTouchStart' in data)
this.on_touch_start = new NavigationEndpoint(data.onTouchStart);
if ('onVisible' in data)
this.on_visible = new NavigationEndpoint(data.onVisible);
if ('onFirstVisible' in data)
this.on_first_visible = new NavigationEndpoint(data.onFirstVisible);
if ('onHover' in data)
this.on_hover = new NavigationEndpoint(data.onHover);
}
}

View File

@@ -1,5 +1,7 @@
import type Player from '../../../core/Player.js';
import type { RawNode } from '../../index.js';
import { FormatXTags } from '../../../../protos/generated/misc/common.js';
import { base64ToU8 } from '../../../utils/Utils.js';
export type ProjectionType = 'RECTANGULAR' | 'EQUIRECTANGULAR' | 'EQUIRECTANGULAR_THREED_TOP_BOTTOM' | 'MESH';
export type SpatialAudioType = 'AMBISONICS_5_1' | 'AMBISONICS_QUAD' | 'FOA_WITH_NON_DIEGETIC';
@@ -211,17 +213,16 @@ export default class Format {
};
if (this.has_audio || this.has_text) {
const args = new URLSearchParams(this.cipher || this.signature_cipher);
const url_components = new URLSearchParams(args.get('url') || this.url);
const xtags = this.xtags
? FormatXTags.decode(base64ToU8(decodeURIComponent(this.xtags).replace(/-/g, '+').replace(/_/g, '/'))).xtags
: [];
const xtags = url_components.get('xtags')?.split(':');
this.language = xtags?.find((x: string) => x.startsWith('lang='))?.split('=')[1] || null;
this.language = xtags.find((tag) => tag.key === 'lang')?.value || null;
if (this.has_audio) {
this.is_drc = !!data.isDrc || !!xtags?.includes('drc=1');
this.is_drc = !!data.isDrc || xtags.some((tag) => tag.key === 'drc' && tag.value === '1');
const audio_content = xtags?.find((x) => x.startsWith('acont='))?.split('=')[1];
const audio_content = xtags.find((tag) => tag.key === 'acont')?.value;
this.is_dubbed = audio_content === 'dubbed';
this.is_descriptive = audio_content === 'descriptive';
this.is_secondary = audio_content === 'secondary';

View File

@@ -1,35 +1,21 @@
import type { RawNode } from '../../types/index.js';
import NavigationEndpoint from '../NavigationEndpoint.js';
export type CommandContext = {
on_tap?: NavigationEndpoint;
};
export type AccessibilityContext = {
label?: string;
};
import CommandContext from './CommandContext.js';
import AccessibilityContext from './AccessibilityContext.js';
export default class RendererContext {
public command_context: CommandContext;
public accessibility_context: AccessibilityContext;
public command_context?: CommandContext;
public accessibility_context?: AccessibilityContext;
constructor(data?: RawNode) {
this.command_context = {};
this.accessibility_context = {};
if (!data)
return;
if (Reflect.has(data, 'commandContext')) {
if (Reflect.has(data.commandContext, 'onTap')) {
this.command_context.on_tap = new NavigationEndpoint(data.commandContext.onTap);
}
if ('commandContext' in data) {
this.command_context = new CommandContext(data.commandContext);
}
if (Reflect.has(data, 'accessibilityContext')) {
if (Reflect.has(data.accessibilityContext, 'label')) {
this.accessibility_context.label = data.accessibilityContext.label;
}
if ('accessibilityContext' in data) {
this.accessibility_context = new AccessibilityContext(data.accessibilityContext);
}
}
}

View File

@@ -0,0 +1,17 @@
import type { RawNode } from '../../index.js';
import Text from './Text.js';
export default class SubscriptionButton {
static type = 'SubscriptionButton';
public text: Text;
public subscribed: boolean;
public subscription_type?: 'FREE' | 'PAID' | 'UNAVAILABLE';
constructor(data: RawNode) {
this.text = new Text(data.text);
this.subscribed = data.isSubscribed;
if ('subscriptionType' in data)
this.subscription_type = data.subscriptionType;
}
}

View File

@@ -1,11 +1,14 @@
// This file was auto generated, do not edit.
// See ./scripts/build-parser-map.js
export { default as AccessibilityContext } from './classes/misc/AccessibilityContext.js';
export { default as Author } from './classes/misc/Author.js';
export { default as ChildElement } from './classes/misc/ChildElement.js';
export { default as CommandContext } from './classes/misc/CommandContext.js';
export { default as EmojiRun } from './classes/misc/EmojiRun.js';
export { default as Format } from './classes/misc/Format.js';
export { default as RendererContext } from './classes/misc/RendererContext.js';
export { default as SubscriptionButton } from './classes/misc/SubscriptionButton.js';
export { default as Text } from './classes/misc/Text.js';
export { default as TextRun } from './classes/misc/TextRun.js';
export { default as Thumbnail } from './classes/misc/Thumbnail.js';

View File

@@ -13,7 +13,7 @@ import MicroformatData from '../classes/MicroformatData.js';
import SubscribeButton from '../classes/SubscribeButton.js';
import ExpandableTab from '../classes/ExpandableTab.js';
import SectionList from '../classes/SectionList.js';
import Tab from '../classes/Tab.js';
import type Tab from '../classes/Tab.js';
import PageHeader from '../classes/PageHeader.js';
import TwoColumnBrowseResults from '../classes/TwoColumnBrowseResults.js';
import ChipCloudChip from '../classes/ChipCloudChip.js';
@@ -62,7 +62,7 @@ export default class Channel extends TabbedFeed<IBrowseResponse> {
this.subscribe_button = this.page.header_memo?.getType(SubscribeButton)[0];
if (this.page.contents)
this.current_tab = this.page.contents.item().as(TwoColumnBrowseResults).tabs.array().filterType(Tab, ExpandableTab).get({ selected: true });
this.current_tab = this.page.contents.item().as(TwoColumnBrowseResults).tabs.get({ selected: true });
}
/**

View File

@@ -37,7 +37,7 @@ export default class History extends Feed<IBrowseResponse> {
for (const section of this.sections) {
for (const content of section.contents) {
const video = content as Video;
if (video.id === video_id && video.menu) {
if (video.video_id === video_id && video.menu) {
feedbackToken = video.menu.top_level_buttons[0].as(Button).endpoint.payload.feedbackToken;
break;
}

View File

@@ -11,7 +11,6 @@ import SettingsSwitch from '../classes/SettingsSwitch.js';
import CommentsHeader from '../classes/comments/CommentsHeader.js';
import ItemSectionHeader from '../classes/ItemSectionHeader.js';
import ItemSectionTabbedHeader from '../classes/ItemSectionTabbedHeader.js';
import Tab from '../classes/Tab.js';
import TwoColumnBrowseResults from '../classes/TwoColumnBrowseResults.js';
import type { ApiResponse, Actions } from '../../core/index.js';
@@ -34,7 +33,7 @@ export default class Settings {
if (!this.#page.contents)
throw new InnertubeError('Page contents not found');
const tab = this.#page.contents.item().as(TwoColumnBrowseResults).tabs.array().as(Tab).get({ selected: true });
const tab = this.#page.contents.item().as(TwoColumnBrowseResults).tabs.get({ selected: true });
if (!tab)
throw new InnertubeError('Target tab not found');

View File

@@ -215,6 +215,9 @@ export default class VideoInfo extends MediaInfo {
if (like_status === 'LIKE')
throw new InnertubeError('This video is already liked', { video_id: this.basic_info.id });
if (!button.default_button.on_tap)
throw new InnertubeError('onTap command not found', { video_id: this.basic_info.id });
const endpoint = new NavigationEndpoint(button.default_button.on_tap.payload.commands.find((cmd: RawNode) => cmd.innertubeCommand));
return await endpoint.call(this.actions);
@@ -252,6 +255,9 @@ export default class VideoInfo extends MediaInfo {
if (like_status === 'DISLIKE')
throw new InnertubeError('This video is already disliked', { video_id: this.basic_info.id });
if (!button.default_button.on_tap)
throw new InnertubeError('onTap command not found', { video_id: this.basic_info.id });
const endpoint = new NavigationEndpoint(button.default_button.on_tap.payload.commands.find((cmd: RawNode) => cmd.innertubeCommand));
return await endpoint.call(this.actions);
@@ -298,7 +304,10 @@ export default class VideoInfo extends MediaInfo {
if (!button || !button.toggled_button)
throw new InnertubeError('Like/Dislike button not found', { video_id: this.basic_info.id });
if (!button.toggled_button.on_tap)
throw new InnertubeError('onTap command not found', { video_id: this.basic_info.id });
const endpoint = new NavigationEndpoint(button.toggled_button.on_tap.payload.commands.find((cmd: RawNode) => cmd.innertubeCommand));
return await endpoint.call(this.actions);
@@ -401,7 +410,7 @@ export default class VideoInfo extends MediaInfo {
// If the song isn't in the video_lockup, it should be in the info_rows
song = lookup.video_lockup?.title?.toString();
// If the video id isn't in the video_lockup, it should be in the info_rows
videoId = lookup.video_lockup?.endpoint.payload.videoId;
videoId = lookup.video_lockup?.endpoint?.payload.videoId;
for (let i = 0; i < lookup.info_rows.length; i++) {
const info_row = lookup.info_rows[i];
if (info_row.info_row_expand_status_key === undefined) {

View File

@@ -32,7 +32,7 @@ class TrackInfo extends MediaInfo {
if (next) {
const tabbed_results = next.contents_memo?.getType(WatchNextTabbedResults)?.[0];
this.tabs = tabbed_results?.tabs.array().as(Tab);
this.tabs = tabbed_results?.tabs.as(Tab);
this.current_video_endpoint = next.current_video_endpoint;
// TODO: update PlayerOverlay, YTMusic's is a little bit different.

View File

@@ -25,13 +25,15 @@ export const OAUTH = {
export const CLIENTS = {
IOS: {
NAME: 'iOS',
VERSION: '18.06.35',
USER_AGENT: 'com.google.ios.youtube/18.06.35 (iPhone; CPU iPhone OS 14_4 like Mac OS X; en_US)',
DEVICE_MODEL: 'iPhone10,6'
VERSION: '20.11.6',
USER_AGENT: 'com.google.ios.youtube/20.11.6 (iPhone10,4; U; CPU iOS 16_7_7 like Mac OS X)',
DEVICE_MODEL: 'iPhone10,4',
OS_NAME: 'iOS',
OS_VERSION: '16.7.7.20H330'
},
WEB: {
NAME: 'WEB',
VERSION: '2.20241121.01.00',
VERSION: '2.20250222.10.00',
API_KEY: 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
API_VERSION: 'v1',
STATIC_VISITOR_ID: '6zpwvWUNAco',
@@ -39,12 +41,12 @@ export const CLIENTS = {
},
MWEB: {
NAME: 'MWEB',
VERSION: '2.20241205.01.00',
VERSION: '2.20250224.01.00',
API_VERSION: 'v1'
},
WEB_KIDS: {
NAME: 'WEB_KIDS',
VERSION: '2.20230111.00.00'
VERSION: '2.20250221.11.00'
},
YTMUSIC: {
NAME: 'WEB_REMIX',
@@ -66,7 +68,7 @@ export const CLIENTS = {
},
TV: {
NAME: 'TVHTML5',
VERSION: '7.20241016.15.00',
VERSION: '7.20250219.14.00',
USER_AGENT: 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version'
},
TV_EMBEDDED: {
@@ -75,7 +77,7 @@ export const CLIENTS = {
},
WEB_EMBEDDED: {
NAME: 'WEB_EMBEDDED_PLAYER',
VERSION: '2.20240111.09.00',
VERSION: '1.20250219.01.00',
API_KEY: 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
API_VERSION: 'v1',
STATIC_VISITOR_ID: '6zpwvWUNAco'

View File

@@ -33,7 +33,9 @@ export default class HTTPClient {
input: URL | Request | string,
init?: RequestInit & HTTPClientInit
): Promise<Response> {
const innertube_url = Constants.URLS.API.PRODUCTION_1 + this.#session.api_version;
const session = this.#session;
const innertube_url = Constants.URLS.API.PRODUCTION_1 + session.api_version;
const baseURL = init?.baseURL || innertube_url;
const request_url =
@@ -55,17 +57,17 @@ export default class HTTPClient {
request_headers.set('Accept', '*/*');
request_headers.set('Accept-Language', '*');
request_headers.set('X-Goog-Visitor-Id', this.#session.context.client.visitorData || '');
request_headers.set('X-Youtube-Client-Version', this.#session.context.client.clientVersion || '');
request_headers.set('X-Goog-Visitor-Id', session.context.client.visitorData || '');
request_headers.set('X-Youtube-Client-Version', session.context.client.clientVersion || '');
const client_name_id = Constants.CLIENT_NAME_IDS[this.#session.context.client.clientName as keyof typeof Constants.CLIENT_NAME_IDS];
const client_name_id = Constants.CLIENT_NAME_IDS[session.context.client.clientName as keyof typeof Constants.CLIENT_NAME_IDS];
if (client_name_id) {
request_headers.set('X-Youtube-Client-Name', client_name_id);
}
if (Platform.shim.server) {
request_headers.set('User-Agent', this.#session.user_agent || '');
request_headers.set('User-Agent', session.user_agent || '');
request_headers.set('Origin', request_url.origin);
}
@@ -88,7 +90,7 @@ export default class HTTPClient {
const n_body = {
...json,
// Deep copy since we're going to be modifying it
context: JSON.parse(JSON.stringify(this.#session.context)) as Context
context: JSON.parse(JSON.stringify(session.context)) as Context
};
this.#adjustContext(n_body.context, n_body.client);
@@ -121,8 +123,8 @@ export default class HTTPClient {
}
// Authenticate (NOTE: YouTube Kids does not support regular bearer tokens)
if (this.#session.logged_in && is_innertube_req && !is_web_kids) {
const oauth = this.#session.oauth;
if (session.logged_in && is_innertube_req && !is_web_kids) {
const oauth = session.oauth;
if (oauth.oauth2_tokens) {
if (oauth.shouldRefreshToken()) {
@@ -132,17 +134,19 @@ export default class HTTPClient {
request_headers.set('Authorization', `Bearer ${oauth.oauth2_tokens.access_token}`);
}
if (this.#cookie) {
const sapisid = getCookie(this.#cookie, 'SAPISID');
const cookie = this.#cookie;
if (cookie) {
const sapisid = getCookie(cookie, 'SAPISID');
if (sapisid) {
request_headers.set('Authorization', await generateSidAuth(sapisid));
request_headers.set('X-Goog-Authuser', this.#session.account_index.toString());
if (this.#session.context.user.onBehalfOfUser)
request_headers.set('X-Goog-PageId', this.#session.context.user.onBehalfOfUser);
request_headers.set('X-Goog-Authuser', session.account_index.toString());
if (session.context.user.onBehalfOfUser)
request_headers.set('X-Goog-PageId', session.context.user.onBehalfOfUser);
}
request_headers.set('Cookie', this.#cookie);
request_headers.set('Cookie', cookie);
}
}
@@ -197,7 +201,8 @@ export default class HTTPClient {
ctx.client.clientVersion = Constants.CLIENTS.IOS.VERSION;
ctx.client.clientName = Constants.CLIENTS.IOS.NAME;
ctx.client.platform = 'MOBILE';
ctx.client.osName = 'iOS';
ctx.client.osName = Constants.CLIENTS.IOS.NAME;
ctx.client.osVersion = Constants.CLIENTS.IOS.OS_VERSION;
delete ctx.client.browserName;
delete ctx.client.browserVersion;
break;