From 923e9c28e34b00841413824d82d10bf644186edc Mon Sep 17 00:00:00 2001 From: Luan Date: Mon, 3 Mar 2025 03:22:07 -0300 Subject: [PATCH] feat: Add AccessibilityContext and CommandContext classes + improve type definitions and parsing logic across multiple nodes --- src/core/mixins/Feed.ts | 11 +- src/parser/classes/Alert.ts | 4 +- src/parser/classes/BadgeView.ts | 2 +- src/parser/classes/BrowseFeedActions.ts | 10 +- src/parser/classes/ButtonView.ts | 128 ++++++++++++++-- src/parser/classes/CompactVideo.ts | 109 +++++++++----- src/parser/classes/GridVideo.ts | 43 +++--- src/parser/classes/LockupView.ts | 2 +- src/parser/classes/ProfileColumn.ts | 2 +- .../classes/SecondarySearchContainer.ts | 6 +- .../classes/SegmentedLikeDislikeButtonView.ts | 7 +- src/parser/classes/TwoColumnBrowseResults.ts | 16 +- src/parser/classes/TwoColumnSearchResults.ts | 22 ++- src/parser/classes/Video.ts | 141 +++++++++++------- src/parser/classes/VideoOwner.ts | 12 +- src/parser/classes/VideoPrimaryInfo.ts | 8 +- src/parser/classes/VideoSecondaryInfo.ts | 23 +-- .../classes/misc/AccessibilityContext.ts | 9 ++ src/parser/classes/misc/CommandContext.ts | 47 ++++++ src/parser/classes/misc/RendererContext.ts | 30 +--- src/parser/classes/misc/SubscriptionButton.ts | 17 +++ src/parser/misc.ts | 3 + src/parser/youtube/Channel.ts | 4 +- src/parser/youtube/History.ts | 2 +- src/parser/youtube/Settings.ts | 3 +- src/parser/youtube/VideoInfo.ts | 13 +- src/parser/ytmusic/TrackInfo.ts | 2 +- 27 files changed, 474 insertions(+), 202 deletions(-) create mode 100644 src/parser/classes/misc/AccessibilityContext.ts create mode 100644 src/parser/classes/misc/CommandContext.ts create mode 100644 src/parser/classes/misc/SubscriptionButton.ts diff --git a/src/core/mixins/Feed.ts b/src/core/mixins/Feed.ts index af2b1def..2e71f2d1 100644 --- a/src/core/mixins/Feed.ts +++ b/src/core/mixins/Feed.ts @@ -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 { readonly #page: T; @@ -163,14 +166,14 @@ export default class Feed { /** * Returns secondary contents from the page. */ - get secondary_contents(): SuperParsedResult | 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; } diff --git a/src/parser/classes/Alert.ts b/src/parser/classes/Alert.ts index cd1d8e99..2846d2d8 100644 --- a/src/parser/classes/Alert.ts +++ b/src/parser/classes/Alert.ts @@ -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(); diff --git a/src/parser/classes/BadgeView.ts b/src/parser/classes/BadgeView.ts index 5a7a04ba..7994cc6a 100644 --- a/src/parser/classes/BadgeView.ts +++ b/src/parser/classes/BadgeView.ts @@ -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; diff --git a/src/parser/classes/BrowseFeedActions.ts b/src/parser/classes/BrowseFeedActions.ts index d449662f..fdc292c5 100644 --- a/src/parser/classes/BrowseFeedActions.ts +++ b/src/parser/classes/BrowseFeedActions.ts @@ -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; + public contents: ObservedArray; constructor(data: RawNode) { super(); - this.contents = Parser.parseArray(data.contents); + this.contents = Parser.parseArray(data.contents, [ SubFeedSelector, EomSettingsDisclaimer, ToggleButton, CompactLink, SearchBox, Button ]); } } \ No newline at end of file diff --git a/src/parser/classes/ButtonView.ts b/src/parser/classes/ButtonView.ts index d055f2ec..88809b90 100644 --- a/src/parser/classes/ButtonView.ts +++ b/src/parser/classes/ButtonView.ts @@ -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; } } \ No newline at end of file diff --git a/src/parser/classes/CompactVideo.ts b/src/parser/classes/CompactVideo.ts index 002f584e..0838ff17 100644 --- a/src/parser/classes/CompactVideo.ts +++ b/src/parser/classes/CompactVideo.ts @@ -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; - 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; + 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() { diff --git a/src/parser/classes/GridVideo.ts b/src/parser/classes/GridVideo.ts index fc90812e..0409b4ec 100644 --- a/src/parser/classes/GridVideo.ts +++ b/src/parser/classes/GridVideo.ts @@ -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; - rich_thumbnail: YTNode; - published: Text; - duration: Text | null; - author: Author; - views: Text; - short_view_count: Text; - endpoint: NavigationEndpoint; - menu: Menu | null; - buttons?: ObservedArray; - upcoming?: Date; - upcoming_text?: Text; - is_reminder_set?: boolean; + public video_id: string; + public title: Text; + public thumbnails: Thumbnail[]; + public thumbnail_overlays: ObservedArray; + 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; + 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()); } diff --git a/src/parser/classes/LockupView.ts b/src/parser/classes/LockupView.ts index 2e7aa92e..425264ae 100644 --- a/src/parser/classes/LockupView.ts +++ b/src/parser/classes/LockupView.ts @@ -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) { diff --git a/src/parser/classes/ProfileColumn.ts b/src/parser/classes/ProfileColumn.ts index 16a5de54..bf0254cb 100644 --- a/src/parser/classes/ProfileColumn.ts +++ b/src/parser/classes/ProfileColumn.ts @@ -4,7 +4,7 @@ import { Parser, type RawNode } from '../index.js'; export default class ProfileColumn extends YTNode { static type = 'ProfileColumn'; - items: ObservedArray; + public items: ObservedArray; constructor(data: RawNode) { super(); diff --git a/src/parser/classes/SecondarySearchContainer.ts b/src/parser/classes/SecondarySearchContainer.ts index b6505c6a..1b8d3c40 100644 --- a/src/parser/classes/SecondarySearchContainer.ts +++ b/src/parser/classes/SecondarySearchContainer.ts @@ -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; + public target_id?: string; + public contents: ObservedArray; constructor(data: RawNode) { super(); - this.contents = Parser.parseArray(data.contents); + this.contents = Parser.parseArray(data.contents, [ UniversalWatchCard ]); } } \ No newline at end of file diff --git a/src/parser/classes/SegmentedLikeDislikeButtonView.ts b/src/parser/classes/SegmentedLikeDislikeButtonView.ts index 0ef577c2..2ac5125b 100644 --- a/src/parser/classes/SegmentedLikeDislikeButtonView.ts +++ b/src/parser/classes/SegmentedLikeDislikeButtonView.ts @@ -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, '')); } } diff --git a/src/parser/classes/TwoColumnBrowseResults.ts b/src/parser/classes/TwoColumnBrowseResults.ts index 3eef95bb..b6191c36 100644 --- a/src/parser/classes/TwoColumnBrowseResults.ts +++ b/src/parser/classes/TwoColumnBrowseResults.ts @@ -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; - secondary_contents: SuperParsedResult; + public tabs: ObservedArray; + 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 ]); } } \ No newline at end of file diff --git a/src/parser/classes/TwoColumnSearchResults.ts b/src/parser/classes/TwoColumnSearchResults.ts index eb97f24e..19504c31 100644 --- a/src/parser/classes/TwoColumnSearchResults.ts +++ b/src/parser/classes/TwoColumnSearchResults.ts @@ -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; - secondary_contents: SuperParsedResult; - + 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; + } } } \ No newline at end of file diff --git a/src/parser/classes/Video.ts b/src/parser/classes/Video.ts index 428c1455..03ab3d57 100644 --- a/src/parser/classes/Video.ts +++ b/src/parser/classes/Video.ts @@ -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; - 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; + 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 + }; + } } diff --git a/src/parser/classes/VideoOwner.ts b/src/parser/classes/VideoOwner.ts index 8f50d712..65b9de8b 100644 --- a/src/parser/classes/VideoOwner.ts +++ b/src/parser/classes/VideoOwner.ts @@ -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 diff --git a/src/parser/classes/VideoPrimaryInfo.ts b/src/parser/classes/VideoPrimaryInfo.ts index ffa8a8cd..9c4cd74d 100644 --- a/src/parser/classes/VideoPrimaryInfo.ts +++ b/src/parser/classes/VideoPrimaryInfo.ts @@ -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; 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); diff --git a/src/parser/classes/VideoSecondaryInfo.ts b/src/parser/classes/VideoSecondaryInfo.ts index 64581c13..cdc3f430 100644 --- a/src/parser/classes/VideoSecondaryInfo.ts +++ b/src/parser/classes/VideoSecondaryInfo.ts @@ -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); diff --git a/src/parser/classes/misc/AccessibilityContext.ts b/src/parser/classes/misc/AccessibilityContext.ts new file mode 100644 index 00000000..07ca6d7d --- /dev/null +++ b/src/parser/classes/misc/AccessibilityContext.ts @@ -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; + } +} \ No newline at end of file diff --git a/src/parser/classes/misc/CommandContext.ts b/src/parser/classes/misc/CommandContext.ts new file mode 100644 index 00000000..016f4985 --- /dev/null +++ b/src/parser/classes/misc/CommandContext.ts @@ -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); + } +} \ No newline at end of file diff --git a/src/parser/classes/misc/RendererContext.ts b/src/parser/classes/misc/RendererContext.ts index 4cb93cc9..7dfb787c 100644 --- a/src/parser/classes/misc/RendererContext.ts +++ b/src/parser/classes/misc/RendererContext.ts @@ -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); } } } \ No newline at end of file diff --git a/src/parser/classes/misc/SubscriptionButton.ts b/src/parser/classes/misc/SubscriptionButton.ts new file mode 100644 index 00000000..1e6dd4fd --- /dev/null +++ b/src/parser/classes/misc/SubscriptionButton.ts @@ -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; + } +} \ No newline at end of file diff --git a/src/parser/misc.ts b/src/parser/misc.ts index 99a6b2de..4d18e614 100644 --- a/src/parser/misc.ts +++ b/src/parser/misc.ts @@ -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'; diff --git a/src/parser/youtube/Channel.ts b/src/parser/youtube/Channel.ts index 3f38e86f..43317433 100644 --- a/src/parser/youtube/Channel.ts +++ b/src/parser/youtube/Channel.ts @@ -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 { 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 }); } /** diff --git a/src/parser/youtube/History.ts b/src/parser/youtube/History.ts index 5e8d413e..c1317939 100644 --- a/src/parser/youtube/History.ts +++ b/src/parser/youtube/History.ts @@ -37,7 +37,7 @@ export default class History extends Feed { 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; } diff --git a/src/parser/youtube/Settings.ts b/src/parser/youtube/Settings.ts index fbe857ad..bd728438 100644 --- a/src/parser/youtube/Settings.ts +++ b/src/parser/youtube/Settings.ts @@ -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'); diff --git a/src/parser/youtube/VideoInfo.ts b/src/parser/youtube/VideoInfo.ts index 92aa989e..f16d1b0b 100644 --- a/src/parser/youtube/VideoInfo.ts +++ b/src/parser/youtube/VideoInfo.ts @@ -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) { diff --git a/src/parser/ytmusic/TrackInfo.ts b/src/parser/ytmusic/TrackInfo.ts index 3a163613..2dfda171 100644 --- a/src/parser/ytmusic/TrackInfo.ts +++ b/src/parser/ytmusic/TrackInfo.ts @@ -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.