diff --git a/deno/package.json b/deno/package.json index 10dde764..b40c3c06 100644 --- a/deno/package.json +++ b/deno/package.json @@ -1,6 +1,6 @@ { "name": "youtubei.js", - "version": "8.0.0", + "version": "8.1.0", "description": "A wrapper around YouTube's private API. Supports YouTube, YouTube Music, YouTube Kids and YouTube Studio (WIP).", "type": "module", "types": "./dist/src/platform/lib.d.ts", diff --git a/deno/src/Innertube.ts b/deno/src/Innertube.ts index 8cb85046..58e557fc 100644 --- a/deno/src/Innertube.ts +++ b/deno/src/Innertube.ts @@ -280,6 +280,16 @@ export default class Innertube { return new Feed(this.actions, response); } + /** + * Retrieves channels feed. + */ + async getChannelsFeed(): Promise> { + const response = await this.actions.execute( + BrowseEndpoint.PATH, { ...BrowseEndpoint.build({ browse_id: 'FEchannels' }), parse: true } + ); + return new Feed(this.actions, response); + } + /** * Retrieves contents for a given channel. * @param id - Channel id diff --git a/deno/src/core/Actions.ts b/deno/src/core/Actions.ts index 347eea7f..165385a5 100644 --- a/deno/src/core/Actions.ts +++ b/deno/src/core/Actions.ts @@ -167,6 +167,7 @@ export default class Actions { 'FElibrary', 'FEhistory', 'FEsubscriptions', + 'FEchannels', 'FEmusic_listening_review', 'FEmusic_library_landing', 'SPaccount_overview', diff --git a/deno/src/core/mixins/MediaInfo.ts b/deno/src/core/mixins/MediaInfo.ts index aaacebc4..cd2bfb30 100644 --- a/deno/src/core/mixins/MediaInfo.ts +++ b/deno/src/core/mixins/MediaInfo.ts @@ -5,7 +5,7 @@ import type { DownloadOptions, FormatFilter, FormatOptions, URLTransformer } fro import * as FormatUtils from '../../utils/FormatUtils.ts'; import { InnertubeError } from '../../utils/Utils.ts'; import type Format from '../../parser/classes/misc/Format.ts'; -import type { INextResponse, IPlayerResponse } from '../../parser/index.ts'; +import type { INextResponse, IPlayerConfig, IPlayerResponse } from '../../parser/index.ts'; import { Parser } from '../../parser/index.ts'; import type { DashOptions } from '../../types/DashOptions.ts'; import PlayerStoryboardSpec from '../../parser/classes/PlayerStoryboardSpec.ts'; @@ -20,6 +20,7 @@ export default class MediaInfo { #playback_tracking; streaming_data; playability_status; + player_config: IPlayerConfig; constructor(data: [ApiResponse, ApiResponse?], actions: Actions, cpn: string) { this.#actions = actions; @@ -35,6 +36,7 @@ export default class MediaInfo { this.streaming_data = info.streaming_data; this.playability_status = info.playability_status; + this.player_config = info.player_config; this.#playback_tracking = info.playback_tracking; } diff --git a/deno/src/parser/classes/AvatarView.ts b/deno/src/parser/classes/AvatarView.ts new file mode 100644 index 00000000..7e2025a7 --- /dev/null +++ b/deno/src/parser/classes/AvatarView.ts @@ -0,0 +1,30 @@ +import { YTNode } from '../helpers.ts'; +import { type RawNode } from '../index.ts'; +import { Thumbnail } from '../misc.ts'; + +export default class AvatarView extends YTNode { + static type = 'AvatarView'; + + image: { + sources: Thumbnail[], + processor: { + border_image_processor: { + circular: boolean + } + } + }; + avatar_image_size: string; + + constructor(data: RawNode) { + super(); + this.image = { + sources: data.image.sources.map((x: any) => new Thumbnail(x)).sort((a: Thumbnail, b: Thumbnail) => b.width - a.width), + processor: { + border_image_processor: { + circular: data.image.processor.borderImageProcessor.circular + } + } + }; + this.avatar_image_size = data.avatarImageSize; + } +} \ No newline at end of file diff --git a/deno/src/parser/classes/ChannelExternalLinkView.ts b/deno/src/parser/classes/ChannelExternalLinkView.ts index 0440297d..b7ab77df 100644 --- a/deno/src/parser/classes/ChannelExternalLinkView.ts +++ b/deno/src/parser/classes/ChannelExternalLinkView.ts @@ -15,6 +15,6 @@ export default class ChannelExternalLinkView extends YTNode { this.title = Text.fromAttributed(data.title); this.link = Text.fromAttributed(data.link); - this.favicon = data.favicon.sources.map((x: any) => new Thumbnail(x)).sort((a: Thumbnail, b: Thumbnail) => b.width - a.width); + this.favicon = Thumbnail.fromResponse(data.favicon); } } \ No newline at end of file diff --git a/deno/src/parser/classes/ContentPreviewImageView.ts b/deno/src/parser/classes/ContentPreviewImageView.ts index fbbcd639..9b12580a 100644 --- a/deno/src/parser/classes/ContentPreviewImageView.ts +++ b/deno/src/parser/classes/ContentPreviewImageView.ts @@ -10,7 +10,7 @@ export default class ContentPreviewImageView extends YTNode { constructor(data: RawNode) { super(); - this.image = data.image.sources.map((x: any) => new Thumbnail(x)).sort((a: Thumbnail, b: Thumbnail) => b.width - a.width); + this.image = Thumbnail.fromResponse(data.image); this.style = data.style; } } \ No newline at end of file diff --git a/deno/src/parser/classes/DecoratedAvatarView.ts b/deno/src/parser/classes/DecoratedAvatarView.ts new file mode 100644 index 00000000..89458cf2 --- /dev/null +++ b/deno/src/parser/classes/DecoratedAvatarView.ts @@ -0,0 +1,19 @@ +import { YTNode } from '../helpers.ts'; +import type { RawNode } from '../index.ts'; +import NavigationEndpoint from './NavigationEndpoint.ts'; +import AvatarView from './AvatarView.ts'; + +export default class DecoratedAvatarView extends YTNode { + static type = 'DecoratedAvatarView'; + + avatar: AvatarView; + a11y_label: string; + on_tap_endpoint: NavigationEndpoint; + + constructor(data: RawNode) { + super(); + this.avatar = new AvatarView(data.avatar.avatarViewModel); + this.a11y_label = data.a11yLabel; + this.on_tap_endpoint = new NavigationEndpoint(data.rendererContext.commandContext.onTap.innertubeCommand); + } +} \ No newline at end of file diff --git a/deno/src/parser/classes/DislikeButtonView.ts b/deno/src/parser/classes/DislikeButtonView.ts new file mode 100644 index 00000000..dbfd21a0 --- /dev/null +++ b/deno/src/parser/classes/DislikeButtonView.ts @@ -0,0 +1,16 @@ +import { YTNode } from '../helpers.ts'; +import { Parser, type RawNode } from '../index.ts'; +import ToggleButtonView from './ToggleButtonView.ts'; + +export default class DislikeButtonView extends YTNode { + static type = 'DislikeButtonView'; + + toggle_button: ToggleButtonView | null; + dislike_entity_key: string; + + constructor(data: RawNode) { + super(); + this.toggle_button = Parser.parseItem(data.toggleButtonViewModel, ToggleButtonView); + this.dislike_entity_key = data.dislikeEntityKey; + } +} \ No newline at end of file diff --git a/deno/src/parser/classes/FeedNudge.ts b/deno/src/parser/classes/FeedNudge.ts index 0243f54b..bef6016d 100644 --- a/deno/src/parser/classes/FeedNudge.ts +++ b/deno/src/parser/classes/FeedNudge.ts @@ -1,5 +1,6 @@ import { YTNode } from '../helpers.ts'; -import { NavigationEndpoint } from '../nodes.ts'; +import NavigationEndpoint from './NavigationEndpoint.ts'; +import Text from './misc/Text.ts'; import type { RawNode } from '../types/index.ts'; diff --git a/deno/src/parser/classes/HighlightsCarousel.ts b/deno/src/parser/classes/HighlightsCarousel.ts index 8e4f1964..ba940601 100644 --- a/deno/src/parser/classes/HighlightsCarousel.ts +++ b/deno/src/parser/classes/HighlightsCarousel.ts @@ -1,4 +1,5 @@ import NavigationEndpoint from './NavigationEndpoint.ts'; +import Thumbnail from './misc/Thumbnail.ts'; import { YTNode, observe } from '../helpers.ts'; import { type RawNode } from '../index.ts'; @@ -6,11 +7,7 @@ export class Panel extends YTNode { static type = 'Panel'; thumbnail?: { - image: { - url: string; - width: number; - height: number; - }[]; + image: Thumbnail[]; endpoint: NavigationEndpoint; on_long_press_endpoint: NavigationEndpoint; content_mode: string; @@ -18,16 +15,8 @@ export class Panel extends YTNode { }; background_image: { - image: { - url: string; - width: number; - height: number; - }[]; - gradient_image: { - url: string; - width: number; - height: number; - }[]; + image: Thumbnail[]; + gradient_image: Thumbnail[]; }; strapline: string; @@ -48,7 +37,7 @@ export class Panel extends YTNode { if (data.thumbnail) { this.thumbnail = { - image: data.thumbnail.image.sources, + image: Thumbnail.fromResponse(data.thumbnail.image), endpoint: new NavigationEndpoint(data.thumbnail.onTap), on_long_press_endpoint: new NavigationEndpoint(data.thumbnail.onLongPress), content_mode: data.thumbnail.contentMode, @@ -57,8 +46,8 @@ export class Panel extends YTNode { } this.background_image = { - image: data.backgroundImage.image.sources, - gradient_image: data.backgroundImage.gradientImage.sources + image: Thumbnail.fromResponse(data.backgroundImage.image), + gradient_image: Thumbnail.fromResponse(data.backgroundImage.gradientImage) }; this.strapline = data.strapline; diff --git a/deno/src/parser/classes/LikeButtonView.ts b/deno/src/parser/classes/LikeButtonView.ts new file mode 100644 index 00000000..8f03deef --- /dev/null +++ b/deno/src/parser/classes/LikeButtonView.ts @@ -0,0 +1,24 @@ +import { YTNode } from '../helpers.ts'; +import { Parser, type RawNode } from '../index.ts'; +import ToggleButtonView from './ToggleButtonView.ts'; + +export default class LikeButtonView extends YTNode { + static type = 'LikeButtonView'; + + toggle_button: ToggleButtonView | null; + like_status_entity_key: string; + like_status_entity: { + key: string, + like_status: string + }; + + constructor(data: RawNode) { + super(); + this.toggle_button = Parser.parseItem(data.toggleButtonViewModel, ToggleButtonView); + this.like_status_entity_key = data.likeStatusEntityKey; + this.like_status_entity = { + key: data.likeStatusEntity.key, + like_status: data.likeStatusEntity.likeStatus + }; + } +} \ No newline at end of file diff --git a/deno/src/parser/classes/MusicLargeCardItemCarousel.ts b/deno/src/parser/classes/MusicLargeCardItemCarousel.ts index 392fc12f..05b9d06d 100644 --- a/deno/src/parser/classes/MusicLargeCardItemCarousel.ts +++ b/deno/src/parser/classes/MusicLargeCardItemCarousel.ts @@ -1,4 +1,5 @@ import NavigationEndpoint from './NavigationEndpoint.ts'; +import Thumbnail from './misc/Thumbnail.ts'; import { YTNode } from '../helpers.ts'; import type { RawNode } from '../index.ts'; @@ -21,11 +22,7 @@ class ActionButton { class Panel { static type = 'Panel'; - image: { - url: string; - width: number; - height: number; - }[]; + image: Thumbnail[]; content_mode: string; crop_options: string; @@ -34,7 +31,7 @@ class Panel { action_buttons: ActionButton[]; constructor (data: RawNode) { - this.image = data.image.image.sources; + this.image = Thumbnail.fromResponse(data.image.image); this.content_mode = data.image.contentMode; this.crop_options = data.image.cropOptions; this.image_aspect_ratio = data.imageAspectRatio; diff --git a/deno/src/parser/classes/PageHeaderView.ts b/deno/src/parser/classes/PageHeaderView.ts index b18a781e..837e95c9 100644 --- a/deno/src/parser/classes/PageHeaderView.ts +++ b/deno/src/parser/classes/PageHeaderView.ts @@ -2,6 +2,7 @@ import { YTNode } from '../helpers.ts'; import { Parser, type RawNode } from '../index.ts'; import ContentMetadataView from './ContentMetadataView.ts'; import ContentPreviewImageView from './ContentPreviewImageView.ts'; +import DecoratedAvatarView from './DecoratedAvatarView.ts'; import DynamicTextView from './DynamicTextView.ts'; import FlexibleActionsView from './FlexibleActionsView.ts'; @@ -9,14 +10,14 @@ export default class PageHeaderView extends YTNode { static type = 'PageHeaderView'; title: DynamicTextView | null; - image: ContentPreviewImageView | null; + image: ContentPreviewImageView | DecoratedAvatarView | null; metadata: ContentMetadataView | null; actions: FlexibleActionsView | null; constructor(data: RawNode) { super(); this.title = Parser.parseItem(data.title, DynamicTextView); - this.image = Parser.parseItem(data.image, ContentPreviewImageView); + this.image = Parser.parseItem(data.image, [ ContentPreviewImageView, DecoratedAvatarView ]); this.metadata = Parser.parseItem(data.metadata, ContentMetadataView); this.actions = Parser.parseItem(data.actions, FlexibleActionsView); } diff --git a/deno/src/parser/classes/ProductList.ts b/deno/src/parser/classes/ProductList.ts index 7f52061c..1f97d262 100644 --- a/deno/src/parser/classes/ProductList.ts +++ b/deno/src/parser/classes/ProductList.ts @@ -1,4 +1,4 @@ -import type { ObservedArray} from '../helpers.ts'; +import type { ObservedArray } from '../helpers.ts'; import { YTNode } from '../helpers.ts'; import type { RawNode } from '../index.ts'; import { Parser } from '../index.ts'; diff --git a/deno/src/parser/classes/SegmentedLikeDislikeButtonView.ts b/deno/src/parser/classes/SegmentedLikeDislikeButtonView.ts new file mode 100644 index 00000000..785b2d6d --- /dev/null +++ b/deno/src/parser/classes/SegmentedLikeDislikeButtonView.ts @@ -0,0 +1,56 @@ +import { YTNode } from '../helpers.ts'; +import { Parser, type RawNode } from '../index.ts'; +import LikeButtonView from './LikeButtonView.ts'; +import DislikeButtonView from './DislikeButtonView.ts'; + +export default class SegmentedLikeDislikeButtonView extends YTNode { + static type = 'SegmentedLikeDislikeButtonView'; + + like_button: LikeButtonView | null; + dislike_button: DislikeButtonView | null; + icon_type: string; + like_count_entity: { + key: string + }; + dynamic_like_count_update_data: { + update_status_key: string, + placeholder_like_count_values_key: string, + update_delay_loop_id: string, + update_delay_sec: number + }; + + like_count?: number; + short_like_count?: string; + + constructor(data: RawNode) { + super(); + this.like_button = Parser.parseItem(data.likeButtonViewModel, LikeButtonView); + this.dislike_button = Parser.parseItem(data.dislikeButtonViewModel, DislikeButtonView); + this.icon_type = data.iconType; + + if (this.like_button && this.like_button.toggle_button) { + const toggle_button = this.like_button.toggle_button; + + 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, '')); + } 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, '')); + } + } + + this.like_count_entity = { + key: data.likeCountEntity.key + }; + + this.dynamic_like_count_update_data = { + update_status_key: data.dynamicLikeCountUpdateData.updateStatusKey, + placeholder_like_count_values_key: data.dynamicLikeCountUpdateData.placeholderLikeCountValuesKey, + update_delay_loop_id: data.dynamicLikeCountUpdateData.updateDelayLoopId, + update_delay_sec: data.dynamicLikeCountUpdateData.updateDelaySec + }; + } +} \ No newline at end of file diff --git a/deno/src/parser/classes/ToggleButtonView.ts b/deno/src/parser/classes/ToggleButtonView.ts new file mode 100644 index 00000000..9dedf992 --- /dev/null +++ b/deno/src/parser/classes/ToggleButtonView.ts @@ -0,0 +1,20 @@ +import { YTNode } from '../helpers.ts'; +import { Parser, type RawNode } from '../index.ts'; +import ButtonView from './ButtonView.ts'; + +export default class ToggleButtonView extends YTNode { + static type = 'ToggleButtonView'; + + default_button: ButtonView | null; + toggled_button: ButtonView | null; + identifier?: string; + is_toggling_disabled: boolean; + + constructor(data: RawNode) { + super(); + this.default_button = Parser.parseItem(data.defaultButtonViewModel, ButtonView); + this.toggled_button = Parser.parseItem(data.toggledButtonViewModel, ButtonView); + this.identifier = data.identifier; + this.is_toggling_disabled = data.isTogglingDisabled; + } +} \ No newline at end of file diff --git a/deno/src/parser/classes/VideoAttributeView.ts b/deno/src/parser/classes/VideoAttributeView.ts index b588cb8b..0cb3d4b9 100644 --- a/deno/src/parser/classes/VideoAttributeView.ts +++ b/deno/src/parser/classes/VideoAttributeView.ts @@ -10,9 +10,7 @@ import Thumbnail from './misc/Thumbnail.ts'; export default class VideoAttributeView extends YTNode { static type = 'VideoAttributeView'; - image: ContentPreviewImageView | { - sources: Thumbnail[]; - } | null; + image: ContentPreviewImageView | Thumbnail[] | null; image_style: string; title: string; subtitle: string; @@ -26,11 +24,9 @@ export default class VideoAttributeView extends YTNode { constructor(data: RawNode) { super(); - // @NOTE: "image" is not a renderer so not sure why we're parsing it as one. Leaving this hack here for now to avoid breaking things. + if (data.image?.sources) { - this.image = { - sources: data.image.sources.map((x: any) => new Thumbnail(x)).sort((a: Thumbnail, b: Thumbnail) => b.width - a.width) - }; + this.image = Thumbnail.fromResponse(data.image); } else { this.image = Parser.parseItem(data.image, ContentPreviewImageView); } diff --git a/deno/src/parser/classes/VideoDescriptionCourseSection.ts b/deno/src/parser/classes/VideoDescriptionCourseSection.ts index 26794fed..4efee665 100644 --- a/deno/src/parser/classes/VideoDescriptionCourseSection.ts +++ b/deno/src/parser/classes/VideoDescriptionCourseSection.ts @@ -1,4 +1,4 @@ -import type { ObservedArray} from '../helpers.ts'; +import type { ObservedArray } from '../helpers.ts'; import { YTNode } from '../helpers.ts'; import type { RawNode } from '../index.ts'; import { Parser } from '../index.ts'; diff --git a/deno/src/parser/classes/WatchCardSectionSequence.ts b/deno/src/parser/classes/WatchCardSectionSequence.ts index fb13721c..1dd24122 100644 --- a/deno/src/parser/classes/WatchCardSectionSequence.ts +++ b/deno/src/parser/classes/WatchCardSectionSequence.ts @@ -1,4 +1,4 @@ -import type { ObservedArray} from '../helpers.ts'; +import type { ObservedArray } from '../helpers.ts'; import { YTNode } from '../helpers.ts'; import { Parser, type RawNode } from '../index.ts'; diff --git a/deno/src/parser/classes/menus/Menu.ts b/deno/src/parser/classes/menus/Menu.ts index 31ccf1c9..7381c165 100644 --- a/deno/src/parser/classes/menus/Menu.ts +++ b/deno/src/parser/classes/menus/Menu.ts @@ -1,5 +1,5 @@ import { Parser } from '../../index.ts'; -import type { ObservedArray} from '../../helpers.ts'; +import type { ObservedArray } from '../../helpers.ts'; import { YTNode } from '../../helpers.ts'; import type { RawNode } from '../../index.ts'; diff --git a/deno/src/parser/classes/menus/MenuPopup.ts b/deno/src/parser/classes/menus/MenuPopup.ts index b301d145..e30044f9 100644 --- a/deno/src/parser/classes/menus/MenuPopup.ts +++ b/deno/src/parser/classes/menus/MenuPopup.ts @@ -1,4 +1,4 @@ -import type { ObservedArray} from '../../helpers.ts'; +import type { ObservedArray } from '../../helpers.ts'; import { YTNode } from '../../helpers.ts'; import type { RawNode } from '../../index.ts'; import { Parser } from '../../index.ts'; diff --git a/deno/src/parser/classes/menus/MusicMultiSelectMenu.ts b/deno/src/parser/classes/menus/MusicMultiSelectMenu.ts index 2592d6c1..13fa8dae 100644 --- a/deno/src/parser/classes/menus/MusicMultiSelectMenu.ts +++ b/deno/src/parser/classes/menus/MusicMultiSelectMenu.ts @@ -1,4 +1,4 @@ -import type { ObservedArray} from '../../helpers.ts'; +import type { ObservedArray } from '../../helpers.ts'; import { YTNode } from '../../helpers.ts'; import type { RawNode } from '../../index.ts'; import { Parser } from '../../index.ts'; diff --git a/deno/src/parser/classes/menus/SimpleMenuHeader.ts b/deno/src/parser/classes/menus/SimpleMenuHeader.ts index a4bbff06..15e25c6d 100644 --- a/deno/src/parser/classes/menus/SimpleMenuHeader.ts +++ b/deno/src/parser/classes/menus/SimpleMenuHeader.ts @@ -1,4 +1,4 @@ -import type { SuperParsedResult} from '../../helpers.ts'; +import type { SuperParsedResult } from '../../helpers.ts'; import { YTNode } from '../../helpers.ts'; import type { RawNode } from '../../index.ts'; import { Parser } from '../../index.ts'; diff --git a/deno/src/parser/classes/misc/Thumbnail.ts b/deno/src/parser/classes/misc/Thumbnail.ts index c8c94dfb..31f76e18 100644 --- a/deno/src/parser/classes/misc/Thumbnail.ts +++ b/deno/src/parser/classes/misc/Thumbnail.ts @@ -15,7 +15,20 @@ export default class Thumbnail { * Get thumbnails from response object. */ static fromResponse(data: any): Thumbnail[] { - if (!data || !data.thumbnails) return []; - return data.thumbnails.map((x: any) => new Thumbnail(x)).sort((a: Thumbnail, b: Thumbnail) => b.width - a.width); + if (!data) return []; + + let thumbnail_data; + + if (data.thumbnails) { + thumbnail_data = data.thumbnails; + } else if (data.sources) { + thumbnail_data = data.sources; + } + + if (thumbnail_data) { + return thumbnail_data.map((x: any) => new Thumbnail(x)).sort((a: Thumbnail, b: Thumbnail) => b.width - a.width); + } + + return []; } } \ No newline at end of file diff --git a/deno/src/parser/generator.ts b/deno/src/parser/generator.ts index 7d28f9b0..a59995c1 100644 --- a/deno/src/parser/generator.ts +++ b/deno/src/parser/generator.ts @@ -30,27 +30,43 @@ export type MiscInferenceType = { params: [string, string?], } -export type InferenceType = { - type: 'renderer', - renderers: string[], - optional: boolean, -} | { - type: 'renderer_list', - renderers: string[], - optional: boolean, -} | MiscInferenceType | { +export interface ObjectInferenceType { type: 'object', keys: KeyInfo, optional: boolean, -} | { +} + +export interface RendererInferenceType { + type: 'renderer', + renderers: string[], + optional: boolean +} + +export interface PrimativeInferenceType { type: 'primative', - typeof: ('string' | 'number' | 'boolean' | 'bigint' | 'symbol' | 'undefined' | 'function')[], - optional: boolean, -} | { - type: 'unknown', + typeof: ('string' | 'number' | 'boolean' | 'bigint' | 'symbol' | 'undefined' | 'function' | 'never' | 'unknown')[], optional: boolean, } +export type ArrayInferenceType = { + type: 'array', + array_type: 'primitive', + items: PrimativeInferenceType, + optional: boolean, +} | { + type: 'array', + array_type: 'object', + items: ObjectInferenceType, + optional: boolean, +} | { + type: 'array', + array_type: 'renderer', + renderers: string[], + optional: boolean, +}; + +export type InferenceType = RendererInferenceType | MiscInferenceType | ObjectInferenceType | PrimativeInferenceType | ArrayInferenceType; + export type KeyInfo = (readonly [string, InferenceType])[]; const IGNORED_KEYS = new Set([ @@ -70,7 +86,7 @@ export function camelToSnake(str: string) { * @returns The inferred type */ export function inferType(key: string, value: unknown): InferenceType { - let return_value: string | Record | boolean | MiscInferenceType = false; + let return_value: string | Record | false | MiscInferenceType | ArrayInferenceType = false; if (typeof value === 'object' && value != null) { if (return_value = isRenderer(value)) { RENDERER_EXAMPLES[return_value] = Reflect.get(value, Reflect.ownKeys(value)[0]); @@ -85,7 +101,8 @@ export function inferType(key: string, value: unknown): InferenceType { RENDERER_EXAMPLES[key] = value; } return { - type: 'renderer_list', + type: 'array', + array_type: 'renderer', renderers: Object.keys(return_value), optional: false }; @@ -93,6 +110,9 @@ export function inferType(key: string, value: unknown): InferenceType { if (return_value = isMiscType(key, value)) { return return_value as MiscInferenceType; } + if (return_value = isArrayType(value)) { + return return_value as ArrayInferenceType; + } } const primative_type = typeof value; if (primative_type === 'object') @@ -116,6 +136,9 @@ export function inferType(key: string, value: unknown): InferenceType { */ export function isRendererList(value: unknown) { const arr = Array.isArray(value); + if (arr && value.length === 0) + return false; + const is_list = arr && value.every((item) => isRenderer(item)); return ( is_list ? @@ -176,12 +199,89 @@ export function isRenderer(value: unknown) { const is_object = typeof value === 'object'; if (!is_object) return false; const keys = Reflect.ownKeys(value as object); - if (keys.length === 1 && keys[0].toString().includes('Renderer')) { - return Parser.sanitizeClassName(keys[0].toString()); + + if (keys.length === 1) { + const first_key = keys[0].toString(); + + if (first_key.endsWith('Renderer') || first_key.endsWith('Model')) { + return Parser.sanitizeClassName(first_key); + } } return false; } +/** + * Checks if the given value is an array + * @param value - The value to check + * @returns If it is an array, return the InferenceType. Otherwise, return false. + */ +export function isArrayType(value: unknown): false | ArrayInferenceType { + if (!Array.isArray(value)) + return false; + + // If the array is empty, we can't infer anything + if (value.length === 0) + return { + type: 'array', + array_type: 'primitive', + items: { + type: 'primative', + typeof: [ 'never' ], + optional: false + }, + optional: false + }; + // We'll infer the primative type of the array entries + const array_entry_types = value.map((item) => typeof item); + // We only support arrays that have the same primative type throughout + const all_same_type = array_entry_types.every((type) => type === array_entry_types[0]); + if (!all_same_type) + return { + type: 'array', + array_type: 'primitive', + items: { + type: 'primative', + typeof: [ 'unknown' ], + optional: false + }, + optional: false + }; + + const type = array_entry_types[0]; + if (type !== 'object') + return { + type: 'array', + array_type: 'primitive', + items: { + type: 'primative', + typeof: [ type ], + optional: false + }, + optional: false + }; + + let key_type: KeyInfo = []; + for (let i = 0; i < value.length; i++) { + const current_keys = Object.entries(value[i] as object).map(([ key, value ]) => [ key, inferType(key, value) ] as const); + if (i === 0) { + key_type = current_keys; + continue; + } + key_type = mergeKeyInfo(key_type, current_keys).resolved_key_info; + } + + return { + type: 'array', + array_type: 'object', + items: { + type: 'object', + keys: key_type, + optional: false + }, + optional: false + }; +} + function introspectKeysFirstPass(classdata: unknown): KeyInfo { if (typeof classdata !== 'object' || classdata === null) { throw new InnertubeError('Generator: Cannot introspect non-object', { @@ -236,7 +336,7 @@ function introspectKeysSecondPass(key_info: KeyInfo) { // Verify that its actually badges const badge_key_info = key_info.find(([ key ]) => key === cannonical_badges); const is_badges = badge_key_info ? - badge_key_info[1].type === 'renderer_list' && Reflect.has(badge_key_info[1].renderers, 'MetadataBadge') : + badge_key_info[1].type === 'array' && badge_key_info[1].array_type === 'renderer' && Reflect.has(badge_key_info[1].renderers, 'MetadataBadge') : false; if (is_badges && cannonical_badges) excluded_keys.add(cannonical_badges); @@ -273,7 +373,7 @@ export function introspect(classdata: unknown) { const key_info = introspect2(classdata); const dependencies = new Map(); for (const [ , value ] of key_info) { - if (value.type === 'renderer' || value.type === 'renderer_list') + if (value.type === 'renderer' || (value.type === 'array' && value.array_type === 'renderer')) for (const renderer of value.renderers) { const example = RENDERER_EXAMPLES[renderer]; if (example) @@ -401,6 +501,10 @@ export function generateTypescriptClass(classname: string, key_info: KeyInfo) { return `class ${classname} extends YTNode {\n static type = '${classname}';\n\n ${props.join('\n ')}\n\n constructor(data: RawNode) {\n ${constructor_lines.join('\n ')}\n }\n}\n`; } +function toTypeDeclarationObject(indentation: number, keys: KeyInfo) { + return `{\n${keys.map(([ key, value ]) => `${' '.repeat((indentation + 2) * 2)}${camelToSnake(key)}${value.optional ? '?' : ''}: ${toTypeDeclaration(value, indentation + 1)}`).join(',\n')}\n${' '.repeat((indentation + 1) * 2)}}`; +} + /** * For a given inference type, get the typescript type declaration * @param inference_type - The inference type to get the declaration for @@ -413,13 +517,33 @@ export function toTypeDeclaration(inference_type: InferenceType, indentation = 0 { return `${inference_type.renderers.map((type) => `YTNodes.${type}`).join(' | ')} | null`; } - case 'renderer_list': + case 'array': { - return `ObservedArray<${inference_type.renderers.map((type) => `YTNodes.${type}`).join(' | ')}> | null`; + switch (inference_type.array_type) { + case 'renderer': + return `ObservedArray<${inference_type.renderers.map((type) => `YTNodes.${type}`).join(' | ')}> | null`; + + case 'primitive': + { + const items_list = inference_type.items.typeof; + if (inference_type.items.optional && !items_list.includes('undefined')) + items_list.push('undefined'); + const items = + items_list.length === 1 ? + `${items_list[0]}` : `(${items_list.join(' | ')})`; + return `${items}[]`; + } + + case 'object': + return `${toTypeDeclarationObject(indentation, inference_type.items.keys)}[]`; + + default: + throw new Error('Unreachable code reached! Switch missing case!'); + } } case 'object': { - return `{\n${inference_type.keys.map(([ key, value ]) => `${' '.repeat((indentation + 2) * 2)}${camelToSnake(key)}${value.optional ? '?' : ''}: ${toTypeDeclaration(value, indentation + 1)}`).join(',\n')}\n${' '.repeat((indentation + 1) * 2)}}`; + return toTypeDeclarationObject(indentation, inference_type.keys); } case 'misc': switch (inference_type.misc_type) { @@ -430,11 +554,14 @@ export function toTypeDeclaration(inference_type: InferenceType, indentation = 0 } case 'primative': return inference_type.typeof.join(' | '); - case 'unknown': - return '/* TODO: determine correct type */ unknown'; } } +function toParserObject(indentation: number, keys: KeyInfo, key_path: string[], key: string) { + const new_keypath = [ ...key_path, key ]; + return `{\n${keys.map(([ key, value ]) => `${' '.repeat((indentation + 2) * 2)}${camelToSnake(key)}: ${toParser(key, value, new_keypath, indentation + 1)}`).join(',\n')}\n${' '.repeat((indentation + 1) * 2)}}`; +} + /** * Generate statements to parse a given inference type * @param key - The key to parse @@ -448,18 +575,32 @@ export function toParser(key: string, inference_type: InferenceType, key_path: s switch (inference_type.type) { case 'renderer': { - parser = `Parser.parseItem(${key_path.join('.')}.${key}, [ ${inference_type.renderers.map((type) => `YTNodes.${type}`).join(', ')} ])`; + parser = `Parser.parseItem(${key_path.join('.')}.${key}, ${toParserValidTypes(inference_type.renderers)})`; } break; - case 'renderer_list': + case 'array': { - parser = `Parser.parse(${key_path.join('.')}.${key}, true, [ ${inference_type.renderers.map((type) => `YTNodes.${type}`).join(', ')} ])`; + switch (inference_type.array_type) { + case 'renderer': + parser = `Parser.parse(${key_path.join('.')}.${key}, true, ${toParserValidTypes(inference_type.renderers)})`; + break; + + case 'object': + parser = `${key_path.join('.')}.${key}.map((item: any) => (${toParserObject(indentation, inference_type.items.keys, [], 'item')}))`; + break; + + case 'primitive': + parser = `${key_path.join('.')}.${key}`; + break; + + default: + throw new Error('Unreachable code reached! Switch missing case!'); + } } break; case 'object': { - const new_keypath = [ ...key_path, key ]; - parser = `{\n${inference_type.keys.map(([ key, value ]) => `${' '.repeat((indentation + 2) * 2)}${camelToSnake(key)}: ${toParser(key, value, new_keypath, indentation + 1)}`).join(',\n')}\n${' '.repeat((indentation + 1) * 2)}}`; + parser = toParserObject(indentation, inference_type.keys, key_path, key); } break; case 'misc': @@ -482,7 +623,6 @@ export function toParser(key: string, inference_type: InferenceType, key_path: s throw new Error('Unreachable code reached! Switch missing case!'); break; case 'primative': - case 'unknown': parser = `${key_path.join('.')}.${key}`; break; } @@ -491,6 +631,14 @@ export function toParser(key: string, inference_type: InferenceType, key_path: s return parser; } +function toParserValidTypes(types: string[]) { + if (types.length === 1) { + return `YTNodes.${types[0]}`; + } + + return `[ ${types.map((type) => `YTNodes.${type}`).join(', ')} ]`; +} + function accessDataFromKeyPath(root: any, key_path: string[]) { let data = root; for (const key of key_path) @@ -508,6 +656,15 @@ function hasDataFromKeyPath(root: any, key_path: string[]) { return true; } +function parseObject(key: string, data: unknown, key_path: string[], keys: KeyInfo, should_optional: boolean) { + const obj: any = {}; + const new_key_path = [ ...key_path, key ]; + for (const [ key, value ] of keys) { + obj[key] = should_optional ? parse(key, value, data, new_key_path) : undefined; + } + return obj; +} + /** * Parse a value from a given key path using the given inference type * @param key - The key to parse @@ -523,18 +680,26 @@ export function parse(key: string, inference_type: InferenceType, data: unknown, { return should_optional ? Parser.parseItem(accessDataFromKeyPath({ data }, [ ...key_path, key ]), inference_type.renderers.map((type) => Parser.getParserByName(type))) : undefined; } - case 'renderer_list': + case 'array': { - return should_optional ? Parser.parse(accessDataFromKeyPath({ data }, [ ...key_path, key ]), true, inference_type.renderers.map((type) => Parser.getParserByName(type))) : undefined; + switch (inference_type.array_type) { + case 'renderer': + return should_optional ? Parser.parse(accessDataFromKeyPath({ data }, [ ...key_path, key ]), true, inference_type.renderers.map((type) => Parser.getParserByName(type))) : undefined; + break; + + case 'object': + return should_optional ? accessDataFromKeyPath({ data }, [ ...key_path, key ]).map((_: any, idx: number) => { + return parseObject(`${idx}`, data, [ ...key_path, key ], inference_type.items.keys, should_optional); + }) : undefined; + + case 'primitive': + return should_optional ? accessDataFromKeyPath({ data }, [ ...key_path, key ]) : undefined; + } + throw new Error('Unreachable code reached! Switch missing case!'); } case 'object': { - const obj: any = {}; - const new_key_path = [ ...key_path, key ]; - for (const [ key, value ] of inference_type.keys) { - obj[key] = should_optional ? parse(key, value, data, new_key_path) : undefined; - } - return obj; + return parseObject(key, data, key_path, inference_type.keys, should_optional); } case 'misc': switch (inference_type.misc_type) { @@ -556,7 +721,6 @@ export function parse(key: string, inference_type: InferenceType, data: unknown, } throw new Error('Unreachable code reached! Switch missing case!'); case 'primative': - case 'unknown': return accessDataFromKeyPath({ data }, [ ...key_path, key ]); } } @@ -585,7 +749,8 @@ export function mergeKeyInfo(key_info: KeyInfo, new_key_info: KeyInfo) { if (type.type !== new_type.type) { // We've got a type mismatch, this is unknown, we do not resolve unions changed_keys.set(key, { - type: 'unknown', + type: 'primative', + typeof: [ 'unknown' ], optional: true }); continue; @@ -628,27 +793,128 @@ export function mergeKeyInfo(key_info: KeyInfo, new_key_info: KeyInfo) { if (did_change) changed_keys.set(key, resolved_key); } break; - case 'renderer_list': + case 'array': { - if (new_type.type !== 'renderer_list') continue; - const union_map = { - ...type.renderers, - ...new_type.renderers - }; - const either_optional = type.optional || new_type.optional; - const resolved_key: InferenceType = { - type: 'renderer_list', - renderers: union_map, - optional: either_optional - }; - const did_change = JSON.stringify({ - ...resolved_key, - renderers: Object.keys(resolved_key.renderers) - }) !== JSON.stringify({ - ...type, - renderers: Object.keys(type.renderers) - }); - if (did_change) changed_keys.set(key, resolved_key); + if (new_type.type !== 'array') continue; + switch (type.array_type) { + case 'renderer': + { + if (new_type.array_type !== 'renderer') { + // Type mismatch + changed_keys.set(key, { + type: 'array', + array_type: 'primitive', + items: { + type: 'primative', + typeof: [ 'unknown' ], + optional: true + }, + optional: true + }); + continue; + } + const union_map = { + ...type.renderers, + ...new_type.renderers + }; + const either_optional = type.optional || new_type.optional; + const resolved_key: InferenceType = { + type: 'array', + array_type: 'renderer', + renderers: union_map, + optional: either_optional + }; + const did_change = JSON.stringify({ + ...resolved_key, + renderers: Object.keys(resolved_key.renderers) + }) !== JSON.stringify({ + ...type, + renderers: Object.keys(type.renderers) + }); + if (did_change) changed_keys.set(key, resolved_key); + } + break; + + case 'object': + { + if (new_type.array_type === 'primitive' && new_type.items.typeof.length == 1 && new_type.items.typeof[0] === 'never') { + // It's an empty array. We assume the type is unchanged + continue; + } + if (new_type.array_type !== 'object') { + // Type mismatch + changed_keys.set(key, { + type: 'array', + array_type: 'primitive', + items: { + type: 'primative', + typeof: [ 'unknown' ], + optional: true + }, + optional: true + }); + continue; + } + const { resolved_key_info } = mergeKeyInfo(type.items.keys, new_type.items.keys); + const resolved_key: InferenceType = { + type: 'array', + array_type: 'object', + items: { + type: 'object', + keys: resolved_key_info, + optional: type.items.optional || new_type.items.optional + }, + optional: type.optional || new_type.optional + }; + const did_change = JSON.stringify(resolved_key) !== JSON.stringify(type); + if (did_change) changed_keys.set(key, resolved_key); + } + break; + + case 'primitive': + { + if (type.items.typeof.includes('never') && new_type.array_type === 'object') { + // Type is now known from previosly unknown + changed_keys.set(key, new_type); + continue; + } + if (new_type.array_type !== 'primitive') { + // Type mismatch + changed_keys.set(key, { + type: 'array', + array_type: 'primitive', + items: { + type: 'primative', + typeof: [ 'unknown' ], + optional: true + }, + optional: true + }); + continue; + } + + const key_types = new Set([ ...new_type.items.typeof, ...type.items.typeof ]); + if (key_types.size > 1 && key_types.has('never')) + key_types.delete('never'); + + const resolved_key: InferenceType = { + type: 'array', + array_type: 'primitive', + items: { + type: 'primative', + typeof: Array.from(key_types), + optional: type.items.optional || new_type.items.optional + }, + optional: type.optional || new_type.optional + }; + const did_change = JSON.stringify(resolved_key) !== JSON.stringify(type); + if (did_change) changed_keys.set(key, resolved_key); + } + break; + + default: + throw new Error('Unreachable code reached! Switch missing case!'); + } } break; case 'misc': @@ -657,7 +923,8 @@ export function mergeKeyInfo(key_info: KeyInfo, new_key_info: KeyInfo) { if (type.misc_type !== new_type.misc_type) { // We've got a type mismatch, this is unknown, we do not resolve unions changed_keys.set(key, { - type: 'unknown', + type: 'primative', + typeof: [ 'unknown' ], optional: true }); } diff --git a/deno/src/parser/nodes.ts b/deno/src/parser/nodes.ts index d665c488..d9e51cfe 100644 --- a/deno/src/parser/nodes.ts +++ b/deno/src/parser/nodes.ts @@ -22,6 +22,7 @@ export { default as DataModelSection } from './classes/analytics/DataModelSectio export { default as StatRow } from './classes/analytics/StatRow.ts'; export { default as AudioOnlyPlayability } from './classes/AudioOnlyPlayability.ts'; export { default as AutomixPreviewVideo } from './classes/AutomixPreviewVideo.ts'; +export { default as AvatarView } from './classes/AvatarView.ts'; export { default as BackstageImage } from './classes/BackstageImage.ts'; export { default as BackstagePost } from './classes/BackstagePost.ts'; export { default as BackstagePostThread } from './classes/BackstagePostThread.ts'; @@ -93,9 +94,11 @@ export { default as ContinuationItem } from './classes/ContinuationItem.ts'; export { default as ConversationBar } from './classes/ConversationBar.ts'; export { default as CopyLink } from './classes/CopyLink.ts'; export { default as CreatePlaylistDialog } from './classes/CreatePlaylistDialog.ts'; +export { default as DecoratedAvatarView } from './classes/DecoratedAvatarView.ts'; export { default as DecoratedPlayerBar } from './classes/DecoratedPlayerBar.ts'; export { default as DefaultPromoPanel } from './classes/DefaultPromoPanel.ts'; export { default as DidYouMean } from './classes/DidYouMean.ts'; +export { default as DislikeButtonView } from './classes/DislikeButtonView.ts'; export { default as DownloadButton } from './classes/DownloadButton.ts'; export { default as Dropdown } from './classes/Dropdown.ts'; export { default as DropdownItem } from './classes/DropdownItem.ts'; @@ -158,6 +161,7 @@ export { default as ItemSectionHeader } from './classes/ItemSectionHeader.ts'; export { default as ItemSectionTab } from './classes/ItemSectionTab.ts'; export { default as ItemSectionTabbedHeader } from './classes/ItemSectionTabbedHeader.ts'; export { default as LikeButton } from './classes/LikeButton.ts'; +export { default as LikeButtonView } from './classes/LikeButtonView.ts'; export { default as LiveChat } from './classes/LiveChat.ts'; export { default as AddBannerToLiveChatCommand } from './classes/livechat/AddBannerToLiveChatCommand.ts'; export { default as AddChatItemAction } from './classes/livechat/AddChatItemAction.ts'; @@ -328,6 +332,7 @@ export { default as SearchSuggestionsSection } from './classes/SearchSuggestions export { default as SecondarySearchContainer } from './classes/SecondarySearchContainer.ts'; export { default as SectionList } from './classes/SectionList.ts'; export { default as SegmentedLikeDislikeButton } from './classes/SegmentedLikeDislikeButton.ts'; +export { default as SegmentedLikeDislikeButtonView } from './classes/SegmentedLikeDislikeButtonView.ts'; export { default as SettingBoolean } from './classes/SettingBoolean.ts'; export { default as SettingsCheckbox } from './classes/SettingsCheckbox.ts'; export { default as SettingsOptions } from './classes/SettingsOptions.ts'; @@ -373,6 +378,7 @@ export { default as ThumbnailOverlayToggleButton } from './classes/ThumbnailOver export { default as TimedMarkerDecoration } from './classes/TimedMarkerDecoration.ts'; export { default as TitleAndButtonListHeader } from './classes/TitleAndButtonListHeader.ts'; export { default as ToggleButton } from './classes/ToggleButton.ts'; +export { default as ToggleButtonView } from './classes/ToggleButtonView.ts'; export { default as ToggleMenuServiceItem } from './classes/ToggleMenuServiceItem.ts'; export { default as Tooltip } from './classes/Tooltip.ts'; export { default as TopicChannelDetails } from './classes/TopicChannelDetails.ts'; diff --git a/deno/src/parser/parser.ts b/deno/src/parser/parser.ts index 3d55bebd..9b2ac649 100644 --- a/deno/src/parser/parser.ts +++ b/deno/src/parser/parser.ts @@ -398,6 +398,28 @@ export function parseResponse(data: parsed_data.streaming_data = streaming_data; } + if (data.playerConfig) { + const player_config = { + audio_config: { + loudness_db: data.playerConfig.audioConfig?.loudnessDb, + perceptual_loudness_db: data.playerConfig.audioConfig?.perceptualLoudnessDb, + enable_per_format_loudness: data.playerConfig.audioConfig?.enablePerFormatLoudness + }, + stream_selection_config: { + max_bitrate: data.playerConfig.streamSelectionConfig?.maxBitrate || '0' + }, + media_common_config: { + dynamic_readahead_config: { + max_read_ahead_media_time_ms: data.playerConfig.mediaCommonConfig?.dynamicReadaheadConfig?.maxReadAheadMediaTimeMs || 0, + min_read_ahead_media_time_ms: data.playerConfig.mediaCommonConfig?.dynamicReadaheadConfig?.minReadAheadMediaTimeMs || 0, + read_ahead_growth_rate_ms: data.playerConfig.mediaCommonConfig?.dynamicReadaheadConfig?.readAheadGrowthRateMs || 0 + } + } + }; + + parsed_data.player_config = player_config; + } + const current_video_endpoint = data.currentVideoEndpoint ? new NavigationEndpoint(data.currentVideoEndpoint) : null; if (current_video_endpoint) { parsed_data.current_video_endpoint = current_video_endpoint; @@ -545,6 +567,7 @@ export function parseArray(data?: RawNode[], validTypes?: YTNodeConstructor | YT * @param validTypes - YTNode types that are allowed to be parsed. */ export function parse[]>(data: RawData, requireArray: true, validTypes?: K): ObservedArray> | null; +export function parse>(data: RawData, requireArray: true, validTypes?: K): ObservedArray> | null; export function parse(data?: RawData, requireArray?: false | undefined, validTypes?: YTNodeConstructor | YTNodeConstructor[]): SuperParsedResult; export function parse(data?: RawData, requireArray?: boolean, validTypes?: YTNodeConstructor | YTNodeConstructor[]) { if (!data) return null; diff --git a/deno/src/parser/types/ParsedResponse.ts b/deno/src/parser/types/ParsedResponse.ts index 675b13af..309b1235 100644 --- a/deno/src/parser/types/ParsedResponse.ts +++ b/deno/src/parser/types/ParsedResponse.ts @@ -56,6 +56,7 @@ export interface IParsedResponse { }; playability_status?: IPlayabilityStatus; streaming_data?: IStreamingData; + player_config?: IPlayerConfig; current_video_endpoint?: NavigationEndpoint; endpoint?: NavigationEndpoint; captions?: PlayerCaptionsTracklist; @@ -71,6 +72,24 @@ export interface IParsedResponse { continuationEndpoint?: YTNode; } +export interface IPlayerConfig { + audio_config: { + loudness_db?: number; + perceptual_loudness_db?: number; + enable_per_format_loudness: boolean; + }; + stream_selection_config: { + max_bitrate: string; + }; + media_common_config: { + dynamic_readahead_config: { + max_read_ahead_media_time_ms: number; + min_read_ahead_media_time_ms: number; + read_ahead_growth_rate_ms: number; + }; + }; +} + export interface IStreamingData { expires: Date; formats: Format[]; @@ -87,6 +106,7 @@ export interface IPlayerResponse { annotations?: ObservedArray; playability_status: IPlayabilityStatus; streaming_data?: IStreamingData; + player_config: IPlayerConfig; playback_tracking?: { videostats_watchtime_url: string; videostats_playback_url: string; diff --git a/deno/src/parser/types/RawResponse.ts b/deno/src/parser/types/RawResponse.ts index 8dd05f2e..6dac01a0 100644 --- a/deno/src/parser/types/RawResponse.ts +++ b/deno/src/parser/types/RawResponse.ts @@ -1,6 +1,24 @@ export type RawNode = Record; export type RawData = RawNode | RawNode[]; +export interface IRawPlayerConfig { + audioConfig: { + loudnessDb?: number; + perceptualLoudnessDb?: number; + enablePerFormatLoudness: boolean; + }; + streamSelectionConfig: { + maxBitrate: string; + }; + mediaCommonConfig: { + dynamicReadaheadConfig: { + maxReadAheadMediaTimeMs: number; + minReadAheadMediaTimeMs: number; + readAheadGrowthRateMs: number; + }; + }; +} + export interface IRawResponse { contents?: RawData; onResponseReceivedActions?: RawNode[]; @@ -41,6 +59,7 @@ export interface IRawResponse { dashManifestUrl?: string; hlsManifestUrl?: string; }; + playerConfig?: IRawPlayerConfig; currentVideoEndpoint?: RawNode; unseenCount?: number; playlistId?: string; diff --git a/deno/src/parser/youtube/Guide.ts b/deno/src/parser/youtube/Guide.ts index 421c06e9..37ffc864 100644 --- a/deno/src/parser/youtube/Guide.ts +++ b/deno/src/parser/youtube/Guide.ts @@ -1,5 +1,5 @@ import type { IGuideResponse } from '../types/ParsedResponse.ts'; -import type { IRawResponse} from '../index.ts'; +import type { IRawResponse } from '../index.ts'; import { Parser } from '../index.ts'; import type { ObservedArray } from '../helpers.ts'; import GuideSection from '../classes/GuideSection.ts'; diff --git a/deno/src/parser/youtube/VideoInfo.ts b/deno/src/parser/youtube/VideoInfo.ts index 343641d7..dedc8c2d 100644 --- a/deno/src/parser/youtube/VideoInfo.ts +++ b/deno/src/parser/youtube/VideoInfo.ts @@ -12,13 +12,14 @@ import RelatedChipCloud from '../classes/RelatedChipCloud.ts'; import RichMetadata from '../classes/RichMetadata.ts'; import RichMetadataRow from '../classes/RichMetadataRow.ts'; import SegmentedLikeDislikeButton from '../classes/SegmentedLikeDislikeButton.ts'; +import SegmentedLikeDislikeButtonView from '../classes/SegmentedLikeDislikeButtonView.ts'; import ToggleButton from '../classes/ToggleButton.ts'; import TwoColumnWatchNextResults from '../classes/TwoColumnWatchNextResults.ts'; import VideoPrimaryInfo from '../classes/VideoPrimaryInfo.ts'; import VideoSecondaryInfo from '../classes/VideoSecondaryInfo.ts'; -import LiveChatWrap from './LiveChat.ts'; -import type NavigationEndpoint from '../classes/NavigationEndpoint.ts'; +import NavigationEndpoint from '../classes/NavigationEndpoint.ts'; import PlayerLegacyDesktopYpcTrailer from '../classes/PlayerLegacyDesktopYpcTrailer.ts'; +import LiveChatWrap from './LiveChat.ts'; import type CardCollection from '../classes/CardCollection.ts'; import type Endscreen from '../classes/Endscreen.ts'; @@ -35,6 +36,7 @@ import { InnertubeError } from '../../utils/Utils.ts'; import { MediaInfo } from '../../core/mixins/index.ts'; import StructuredDescriptionContent from '../classes/StructuredDescriptionContent.ts'; import { VideoDescriptionMusicSection } from '../nodes.ts'; +import type { RawNode } from '../index.ts'; class VideoInfo extends MediaInfo { #watch_next_continuation?: ContinuationItem; @@ -105,11 +107,10 @@ class VideoInfo extends MediaInfo { // The combined formats only exist for the default language, even for videos with multiple audio tracks // So we can copy the language from the default audio track to the combined formats this.streaming_data.formats.forEach((format) => format.language = default_audio_track.language); - } else if (typeof this.captions?.default_audio_track_index !== 'undefined' && this.captions?.audio_tracks && this.captions.caption_tracks) { + } else if (this.captions?.caption_tracks && this.captions?.caption_tracks.length > 0) { // For videos with a single audio track and captions, we can use the captions to figure out the language of the audio and combined formats - const audioTrack = this.captions.audio_tracks[this.captions.default_audio_track_index]; - const index = audioTrack.default_caption_track_index || 0; - const language_code = this.captions.caption_tracks[index].language_code; + const auto_generated_caption_track = this.captions.caption_tracks.find((caption) => caption.kind === 'asr'); + const language_code = auto_generated_caption_track?.language_code; this.streaming_data.adaptive_formats.forEach((format) => { if (format.has_audio) { @@ -164,6 +165,17 @@ class VideoInfo extends MediaInfo { this.basic_info.is_disliked = segmented_like_dislike_button?.dislike_button?.is_toggled; } + const segmented_like_dislike_button_view = this.primary_info?.menu?.top_level_buttons.firstOfType(SegmentedLikeDislikeButtonView); + if (segmented_like_dislike_button_view) { + this.basic_info.like_count = segmented_like_dislike_button_view.like_count; + + if (segmented_like_dislike_button_view.like_button) { + const like_status = segmented_like_dislike_button_view.like_button.like_status_entity.like_status; + this.basic_info.is_liked = like_status === 'LIKE'; + this.basic_info.is_disliked = like_status === 'DISLIKE'; + } + } + const comments_entry_point = results.get({ target_id: 'comments-entry-point' })?.as(ItemSection); this.comments_entry_point_header = comments_entry_point?.contents?.firstOfType(CommentsEntryPointHeader); @@ -239,6 +251,26 @@ class VideoInfo extends MediaInfo { * Likes the video. */ async like(): Promise { + const segmented_like_dislike_button_view = this.primary_info?.menu?.top_level_buttons.firstOfType(SegmentedLikeDislikeButtonView); + + if (segmented_like_dislike_button_view) { + const button = segmented_like_dislike_button_view?.like_button?.toggle_button; + + if (!button || !button.default_button || !segmented_like_dislike_button_view.like_button) + throw new InnertubeError('Like button not found', { video_id: this.basic_info.id }); + + const like_status = segmented_like_dislike_button_view.like_button.like_status_entity.like_status; + + if (like_status === 'LIKE') + throw new InnertubeError('This video is already liked', { video_id: this.basic_info.id }); + + const endpoint = new NavigationEndpoint(button.default_button.on_tap.payload.commands.find((cmd: RawNode) => cmd.innertubeCommand)); + + const response = await endpoint.call(this.actions); + + return response; + } + const segmented_like_dislike_button = this.primary_info?.menu?.top_level_buttons.firstOfType(SegmentedLikeDislikeButton); const button = segmented_like_dislike_button?.like_button; @@ -260,6 +292,26 @@ class VideoInfo extends MediaInfo { * Dislikes the video. */ async dislike(): Promise { + const segmented_like_dislike_button_view = this.primary_info?.menu?.top_level_buttons.firstOfType(SegmentedLikeDislikeButtonView); + + if (segmented_like_dislike_button_view) { + const button = segmented_like_dislike_button_view?.dislike_button?.toggle_button; + + if (!button || !button.default_button || !segmented_like_dislike_button_view.dislike_button || !segmented_like_dislike_button_view.like_button) + throw new InnertubeError('Dislike button not found', { video_id: this.basic_info.id }); + + const like_status = segmented_like_dislike_button_view.like_button.like_status_entity.like_status; + + if (like_status === 'DISLIKE') + throw new InnertubeError('This video is already disliked', { video_id: this.basic_info.id }); + + const endpoint = new NavigationEndpoint(button.default_button.on_tap.payload.commands.find((cmd: RawNode) => cmd.innertubeCommand)); + + const response = await endpoint.call(this.actions); + + return response; + } + const segmented_like_dislike_button = this.primary_info?.menu?.top_level_buttons.firstOfType(SegmentedLikeDislikeButton); const button = segmented_like_dislike_button?.dislike_button; @@ -283,6 +335,34 @@ class VideoInfo extends MediaInfo { async removeRating(): Promise { let button; + const segmented_like_dislike_button_view = this.primary_info?.menu?.top_level_buttons.firstOfType(SegmentedLikeDislikeButtonView); + + if (segmented_like_dislike_button_view) { + const toggle_button = segmented_like_dislike_button_view?.like_button?.toggle_button; + + if (!toggle_button || !toggle_button.default_button || !segmented_like_dislike_button_view.like_button) + throw new InnertubeError('Like button not found', { video_id: this.basic_info.id }); + + const like_status = segmented_like_dislike_button_view.like_button.like_status_entity.like_status; + + if (like_status === 'LIKE') { + button = segmented_like_dislike_button_view?.like_button?.toggle_button; + } else if (like_status === 'DISLIKE') { + button = segmented_like_dislike_button_view?.dislike_button?.toggle_button; + } else { + throw new InnertubeError('This video is not liked/disliked', { video_id: this.basic_info.id }); + } + + if (!button || !button.toggled_button) + throw new InnertubeError('Like/Dislike button not found', { video_id: this.basic_info.id }); + + const endpoint = new NavigationEndpoint(button.toggled_button.on_tap.payload.commands.find((cmd: RawNode) => cmd.innertubeCommand)); + + const response = await endpoint.call(this.actions); + + return response; + } + const segmented_like_dislike_button = this.primary_info?.menu?.top_level_buttons.firstOfType(SegmentedLikeDislikeButton); const like_button = segmented_like_dislike_button?.like_button;