Skip to content

Instantly share code, notes, and snippets.

@m0o0scar
Created August 20, 2024 11:00
Show Gist options
  • Save m0o0scar/81e591ba966117e0746a9268b8519354 to your computer and use it in GitHub Desktop.
Save m0o0scar/81e591ba966117e0746a9268b8519354 to your computer and use it in GitHub Desktop.
LuanRT/YouTube.js. Continue this conversation at https://readfm.vercel.app?gist=81e591ba966117e0746a9268b8519354

[github] LuanRT/YouTube.js

Source

TypeScript / 32.0K lines of code. A wrapper around YouTube's internal API β€” reverse engineering InnerTube

URL: https://github.com/LuanRT/YouTube.js

Conversation

β–² How do i fetch youtube caption with this package?

You can fetch YouTube captions using the VideoInfo#getTranscript() method. Here's an example:

import { Innertube } from 'youtubei.js';

(async () => {
  const yt = await Innertube.create();

  // Get the video info
  const videoInfo = await yt.getInfo('videoId');

  // Get the transcript
  const transcript = await videoInfo.getTranscript();

  // Print the transcript
  console.log(transcript);
})();

This code will first create an Innertube instance and then fetch the video info for the video with the ID videoId. Then, it will call getTranscript() on the videoInfo object to retrieve the transcript. Finally, it will print the transcript to the console.

Note:

  • The getTranscript() method returns a TranscriptInfo object, which contains the transcript data.
  • The TranscriptInfo object has a languages property, which contains a list of available languages for the transcript.
  • You can use the selectLanguage() method on the TranscriptInfo object to select a specific language for the transcript.

Let me know if you have any other questions.

Data Source Content

Full data source content here Project: LuanRT/YouTube.js

URL: https://github.com/LuanRT/YouTube.js

Source tree:

LuanRT/YouTube.js/blob/main
β”œβ”€β”€ CHANGELOG.md
β”œβ”€β”€ COLLABORATORS.md
β”œβ”€β”€ CONTRIBUTING.md
β”œβ”€β”€ README.md
β”œβ”€β”€ bundle/browser.d.ts
β”œβ”€β”€ deno.ts
β”œβ”€β”€ dev-scripts
|  β”œβ”€β”€ gen-parser-map.mjs
|  └── get-agents.mjs
β”œβ”€β”€ docs
|  β”œβ”€β”€ API
|  |  β”œβ”€β”€ account.md
|  |  β”œβ”€β”€ feed.md
|  |  β”œβ”€β”€ filterable-feed.md
|  |  β”œβ”€β”€ interaction-manager.md
|  |  β”œβ”€β”€ kids.md
|  |  β”œβ”€β”€ music.md
|  |  β”œβ”€β”€ playlist.md
|  |  β”œβ”€β”€ session.md
|  |  β”œβ”€β”€ studio.md
|  |  └── tabbed-feed.md
|  └── updating-the-parser.md
β”œβ”€β”€ jest.config.js
β”œβ”€β”€ package.json
└── src
   β”œβ”€β”€ Innertube.ts
   β”œβ”€β”€ core
   |  β”œβ”€β”€ Actions.ts
   |  β”œβ”€β”€ OAuth2.ts
   |  β”œβ”€β”€ Player.ts
   |  β”œβ”€β”€ Session.ts
   |  β”œβ”€β”€ clients
   |  |  β”œβ”€β”€ Kids.ts
   |  |  β”œβ”€β”€ Music.ts
   |  |  β”œβ”€β”€ Studio.ts
   |  |  └── index.ts
   |  β”œβ”€β”€ endpoints
   |  |  β”œβ”€β”€ BrowseEndpoint.ts
   |  |  β”œβ”€β”€ GetNotificationMenuEndpoint.ts
   |  |  β”œβ”€β”€ GuideEndpoint.ts
   |  |  β”œβ”€β”€ NextEndpoint.ts
   |  |  β”œβ”€β”€ PlayerEndpoint.ts
   |  |  β”œβ”€β”€ ResolveURLEndpoint.ts
   |  |  β”œβ”€β”€ SearchEndpoint.ts
   |  |  β”œβ”€β”€ account
   |  |  |  β”œβ”€β”€ AccountListEndpoint.ts
   |  |  |  └── index.ts
   |  |  β”œβ”€β”€ browse
   |  |  |  β”œβ”€β”€ EditPlaylistEndpoint.ts
   |  |  |  └── index.ts
   |  |  β”œβ”€β”€ channel
   |  |  |  β”œβ”€β”€ EditDescriptionEndpoint.ts
   |  |  |  β”œβ”€β”€ EditNameEndpoint.ts
   |  |  |  └── index.ts
   |  |  β”œβ”€β”€ comment
   |  |  |  β”œβ”€β”€ CreateCommentEndpoint.ts
   |  |  |  β”œβ”€β”€ PerformCommentActionEndpoint.ts
   |  |  |  └── index.ts
   |  |  β”œβ”€β”€ index.ts
   |  |  β”œβ”€β”€ kids
   |  |  |  β”œβ”€β”€ BlocklistPickerEndpoint.ts
   |  |  |  └── index.ts
   |  |  β”œβ”€β”€ like
   |  |  |  β”œβ”€β”€ DislikeEndpoint.ts
   |  |  |  β”œβ”€β”€ LikeEndpoint.ts
   |  |  |  β”œβ”€β”€ RemoveLikeEndpoint.ts
   |  |  |  └── index.ts
   |  |  β”œβ”€β”€ music
   |  |  |  β”œβ”€β”€ GetSearchSuggestionsEndpoint.ts
   |  |  |  └── index.ts
   |  |  β”œβ”€β”€ notification
   |  |  |  β”œβ”€β”€ GetUnseenCountEndpoint.ts
   |  |  |  β”œβ”€β”€ ModifyChannelPreferenceEndpoint.ts
   |  |  |  └── index.ts
   |  |  β”œβ”€β”€ playlist
   |  |  |  β”œβ”€β”€ CreateEndpoint.ts
   |  |  |  β”œβ”€β”€ DeleteEndpoint.ts
   |  |  |  └── index.ts
   |  |  β”œβ”€β”€ reel
   |  |  |  β”œβ”€β”€ ReelItemWatchEndpoint.ts
   |  |  |  β”œβ”€β”€ ReelWatchSequenceEndpoint.ts
   |  |  |  └── index.ts
   |  |  β”œβ”€β”€ subscription
   |  |  |  β”œβ”€β”€ SubscribeEndpoint.ts
   |  |  |  β”œβ”€β”€ UnsubscribeEndpoint.ts
   |  |  |  └── index.ts
   |  |  └── upload
   |  |     β”œβ”€β”€ CreateVideoEndpoint.ts
   |  |     └── index.ts
   |  β”œβ”€β”€ index.ts
   |  β”œβ”€β”€ managers
   |  |  β”œβ”€β”€ AccountManager.ts
   |  |  β”œβ”€β”€ InteractionManager.ts
   |  |  β”œβ”€β”€ PlaylistManager.ts
   |  |  └── index.ts
   |  └── mixins
   |     β”œβ”€β”€ Feed.ts
   |     β”œβ”€β”€ FilterableFeed.ts
   |     β”œβ”€β”€ MediaInfo.ts
   |     β”œβ”€β”€ TabbedFeed.ts
   |     └── index.ts
   β”œβ”€β”€ parser
   |  β”œβ”€β”€ README.md
   |  β”œβ”€β”€ classes
   |  |  β”œβ”€β”€ AboutChannel.ts
   |  |  β”œβ”€β”€ AboutChannelView.ts
   |  |  β”œβ”€β”€ AccountChannel.ts
   |  |  β”œβ”€β”€ AccountItemSection.ts
   |  |  β”œβ”€β”€ AccountItemSectionHeader.ts
   |  |  β”œβ”€β”€ AccountSectionList.ts
   |  |  β”œβ”€β”€ Alert.ts
   |  |  β”œβ”€β”€ AlertWithButton.ts
   |  |  β”œβ”€β”€ AttributionView.ts
   |  |  β”œβ”€β”€ AudioOnlyPlayability.ts
   |  |  β”œβ”€β”€ AutomixPreviewVideo.ts
   |  |  β”œβ”€β”€ AvatarView.ts
   |  |  β”œβ”€β”€ BackstageImage.ts
   |  |  β”œβ”€β”€ BackstagePost.ts
   |  |  β”œβ”€β”€ BackstagePostThread.ts
   |  |  β”œβ”€β”€ BrowseFeedActions.ts
   |  |  β”œβ”€β”€ BrowserMediaSession.ts
   |  |  β”œβ”€β”€ Button.ts
   |  |  β”œβ”€β”€ ButtonView.ts
   |  |  β”œβ”€β”€ C4TabbedHeader.ts
   |  |  β”œβ”€β”€ CallToActionButton.ts
   |  |  β”œβ”€β”€ Card.ts
   |  |  β”œβ”€β”€ CardCollection.ts
   |  |  β”œβ”€β”€ CarouselHeader.ts
   |  |  β”œβ”€β”€ CarouselItem.ts
   |  |  β”œβ”€β”€ CarouselLockup.ts
   |  |  β”œβ”€β”€ Channel.ts
   |  |  β”œβ”€β”€ ChannelAboutFullMetadata.ts
   |  |  β”œβ”€β”€ ChannelAgeGate.ts
   |  |  β”œβ”€β”€ ChannelExternalLinkView.ts
   |  |  β”œβ”€β”€ ChannelFeaturedContent.ts
   |  |  β”œβ”€β”€ ChannelHeaderLinks.ts
   |  |  β”œβ”€β”€ ChannelHeaderLinksView.ts
   |  |  β”œβ”€β”€ ChannelMetadata.ts
   |  |  β”œβ”€β”€ ChannelMobileHeader.ts
   |  |  β”œβ”€β”€ ChannelOptions.ts
   |  |  β”œβ”€β”€ ChannelOwnerEmptyState.ts
   |  |  β”œβ”€β”€ ChannelSubMenu.ts
   |  |  β”œβ”€β”€ ChannelTagline.ts
   |  |  β”œβ”€β”€ ChannelThumbnailWithLink.ts
   |  |  β”œβ”€β”€ ChannelVideoPlayer.ts
   |  |  β”œβ”€β”€ Chapter.ts
   |  |  β”œβ”€β”€ ChildVideo.ts
   |  |  β”œβ”€β”€ ChipBarView.ts
   |  |  β”œβ”€β”€ ChipCloud.ts
   |  |  β”œβ”€β”€ ChipCloudChip.ts
   |  |  β”œβ”€β”€ ChipView.ts
   |  |  β”œβ”€β”€ ClipAdState.ts
   |  |  β”œβ”€β”€ ClipCreation.ts
   |  |  β”œβ”€β”€ ClipCreationScrubber.ts
   |  |  β”œβ”€β”€ ClipCreationTextInput.ts
   |  |  β”œβ”€β”€ ClipSection.ts
   |  |  β”œβ”€β”€ CollaboratorInfoCardContent.ts
   |  |  β”œβ”€β”€ CollageHeroImage.ts
   |  |  β”œβ”€β”€ CollectionThumbnailView.ts
   |  |  β”œβ”€β”€ CompactChannel.ts
   |  |  β”œβ”€β”€ CompactLink.ts
   |  |  β”œβ”€β”€ CompactMix.ts
   |  |  β”œβ”€β”€ CompactMovie.ts
   |  |  β”œβ”€β”€ CompactPlaylist.ts
   |  |  β”œβ”€β”€ CompactStation.ts
   |  |  β”œβ”€β”€ CompactVideo.ts
   |  |  β”œβ”€β”€ ConfirmDialog.ts
   |  |  β”œβ”€β”€ ContentMetadataView.ts
   |  |  β”œβ”€β”€ ContentPreviewImageView.ts
   |  |  β”œβ”€β”€ ContinuationItem.ts
   |  |  β”œβ”€β”€ ConversationBar.ts
   |  |  β”œβ”€β”€ CopyLink.ts
   |  |  β”œβ”€β”€ CreatePlaylistDialog.ts
   |  |  β”œβ”€β”€ DecoratedAvatarView.ts
   |  |  β”œβ”€β”€ DecoratedPlayerBar.ts
   |  |  β”œβ”€β”€ DefaultPromoPanel.ts
   |  |  β”œβ”€β”€ DescriptionPreviewView.ts
   |  |  β”œβ”€β”€ DidYouMean.ts
   |  |  β”œβ”€β”€ DislikeButtonView.ts
   |  |  β”œβ”€β”€ DownloadButton.ts
   |  |  β”œβ”€β”€ Dropdown.ts
   |  |  β”œβ”€β”€ DropdownItem.ts
   |  |  β”œβ”€β”€ DynamicTextView.ts
   |  |  β”œβ”€β”€ Element.ts
   |  |  β”œβ”€β”€ EmergencyOnebox.ts
   |  |  β”œβ”€β”€ EmojiPickerCategory.ts
   |  |  β”œβ”€β”€ EmojiPickerCategoryButton.ts
   |  |  β”œβ”€β”€ EmojiPickerUpsellCategory.ts
   |  |  β”œβ”€β”€ EndScreenPlaylist.ts
   |  |  β”œβ”€β”€ EndScreenVideo.ts
   |  |  β”œβ”€β”€ Endscreen.ts
   |  |  β”œβ”€β”€ EndscreenElement.ts
   |  |  β”œβ”€β”€ EngagementPanelSectionList.ts
   |  |  β”œβ”€β”€ EngagementPanelTitleHeader.ts
   |  |  β”œβ”€β”€ EomSettingsDisclaimer.ts
   |  |  β”œβ”€β”€ ExpandableMetadata.ts
   |  |  β”œβ”€β”€ ExpandableTab.ts
   |  |  β”œβ”€β”€ ExpandableVideoDescriptionBody.ts
   |  |  β”œβ”€β”€ ExpandedShelfContents.ts
   |  |  β”œβ”€β”€ Factoid.ts
   |  |  β”œβ”€β”€ FancyDismissibleDialog.ts
   |  |  β”œβ”€β”€ FeedFilterChipBar.ts
   |  |  β”œβ”€β”€ FeedNudge.ts
   |  |  β”œβ”€β”€ FeedTabbedHeader.ts
   |  |  β”œβ”€β”€ FlexibleActionsView.ts
   |  |  β”œβ”€β”€ GameCard.ts
   |  |  β”œβ”€β”€ GameDetails.ts
   |  |  β”œβ”€β”€ Grid.ts
   |  |  β”œβ”€β”€ GridChannel.ts
   |  |  β”œβ”€β”€ GridHeader.ts
   |  |  β”œβ”€β”€ GridMix.ts
   |  |  β”œβ”€β”€ GridMovie.ts
   |  |  β”œβ”€β”€ GridPlaylist.ts
   |  |  β”œβ”€β”€ GridShow.ts
   |  |  β”œβ”€β”€ GridVideo.ts
   |  |  β”œβ”€β”€ GuideCollapsibleEntry.ts
   |  |  β”œβ”€β”€ GuideCollapsibleSectionEntry.ts
   |  |  β”œβ”€β”€ GuideDownloadsEntry.ts
   |  |  β”œβ”€β”€ GuideEntry.ts
   |  |  β”œβ”€β”€ GuideSection.ts
   |  |  β”œβ”€β”€ GuideSubscriptionsSection.ts
   |  |  β”œβ”€β”€ HashtagHeader.ts
   |  |  β”œβ”€β”€ HashtagTile.ts
   |  |  β”œβ”€β”€ HeatMarker.ts
   |  |  β”œβ”€β”€ Heatmap.ts
   |  |  β”œβ”€β”€ HeroPlaylistThumbnail.ts
   |  |  β”œβ”€β”€ HighlightsCarousel.ts
   |  |  β”œβ”€β”€ HistorySuggestion.ts
   |  |  β”œβ”€β”€ HorizontalCardList.ts
   |  |  β”œβ”€β”€ HorizontalList.ts
   |  |  β”œβ”€β”€ HorizontalMovieList.ts
   |  |  β”œβ”€β”€ IconLink.ts
   |  |  β”œβ”€β”€ ImageBannerView.ts
   |  |  β”œβ”€β”€ IncludingResultsFor.ts
   |  |  β”œβ”€β”€ InfoPanelContainer.ts
   |  |  β”œβ”€β”€ InfoPanelContent.ts
   |  |  β”œβ”€β”€ InfoRow.ts
   |  |  β”œβ”€β”€ InteractiveTabbedHeader.ts
   |  |  β”œβ”€β”€ ItemSection.ts
   |  |  β”œβ”€β”€ ItemSectionHeader.ts
   |  |  β”œβ”€β”€ ItemSectionTab.ts
   |  |  β”œβ”€β”€ ItemSectionTabbedHeader.ts
   |  |  β”œβ”€β”€ LikeButton.ts
   |  |  β”œβ”€β”€ LikeButtonView.ts
   |  |  β”œβ”€β”€ LiveChat.ts
   |  |  β”œβ”€β”€ LiveChatAuthorBadge.ts
   |  |  β”œβ”€β”€ LiveChatDialog.ts
   |  |  β”œβ”€β”€ LiveChatHeader.ts
   |  |  β”œβ”€β”€ LiveChatItemList.ts
   |  |  β”œβ”€β”€ LiveChatMessageInput.ts
   |  |  β”œβ”€β”€ LiveChatParticipant.ts
   |  |  β”œβ”€β”€ LiveChatParticipantsList.ts
   |  |  β”œβ”€β”€ LockupMetadataView.ts
   |  |  β”œβ”€β”€ LockupView.ts
   |  |  β”œβ”€β”€ MacroMarkersInfoItem.ts
   |  |  β”œβ”€β”€ MacroMarkersList.ts
   |  |  β”œβ”€β”€ MacroMarkersListItem.ts
   |  |  β”œβ”€β”€ MerchandiseItem.ts
   |  |  β”œβ”€β”€ MerchandiseShelf.ts
   |  |  β”œβ”€β”€ Message.ts
   |  |  β”œβ”€β”€ MetadataBadge.ts
   |  |  β”œβ”€β”€ MetadataRow.ts
   |  |  β”œβ”€β”€ MetadataRowContainer.ts
   |  |  β”œβ”€β”€ MetadataRowHeader.ts
   |  |  β”œβ”€β”€ MetadataScreen.ts
   |  |  β”œβ”€β”€ MicroformatData.ts
   |  |  β”œβ”€β”€ Mix.ts
   |  |  β”œβ”€β”€ ModalWithTitleAndButton.ts
   |  |  β”œβ”€β”€ Movie.ts
   |  |  β”œβ”€β”€ MovingThumbnail.ts
   |  |  β”œβ”€β”€ MultiMarkersPlayerBar.ts
   |  |  β”œβ”€β”€ MusicCardShelf.ts
   |  |  β”œβ”€β”€ MusicCardShelfHeaderBasic.ts
   |  |  β”œβ”€β”€ MusicCarouselShelf.ts
   |  |  β”œβ”€β”€ MusicCarouselShelfBasicHeader.ts
   |  |  β”œβ”€β”€ MusicDescriptionShelf.ts
   |  |  β”œβ”€β”€ MusicDetailHeader.ts
   |  |  β”œβ”€β”€ MusicDownloadStateBadge.ts
   |  |  β”œβ”€β”€ MusicEditablePlaylistDetailHeader.ts
   |  |  β”œβ”€β”€ MusicElementHeader.ts
   |  |  β”œβ”€β”€ MusicHeader.ts
   |  |  β”œβ”€β”€ MusicImmersiveHeader.ts
   |  |  β”œβ”€β”€ MusicInlineBadge.ts
   |  |  β”œβ”€β”€ MusicItemThumbnailOverlay.ts
   |  |  β”œβ”€β”€ MusicLargeCardItemCarousel.ts
   |  |  β”œβ”€β”€ MusicMultiRowListItem.ts
   |  |  β”œβ”€β”€ MusicNavigationButton.ts
   |  |  β”œβ”€β”€ MusicPlayButton.ts
   |  |  β”œβ”€β”€ MusicPlaylistEditHeader.ts
   |  |  β”œβ”€β”€ MusicPlaylistShelf.ts
   |  |  β”œβ”€β”€ MusicQueue.ts
   |  |  β”œβ”€β”€ MusicResponsiveHeader.ts
   |  |  β”œβ”€β”€ MusicResponsiveListItem.ts
   |  |  β”œβ”€β”€ MusicResponsiveListItemFixedColumn.ts
   |  |  β”œβ”€β”€ MusicResponsiveListItemFlexColumn.ts
   |  |  β”œβ”€β”€ MusicShelf.ts
   |  |  β”œβ”€β”€ MusicSideAlignedItem.ts
   |  |  β”œβ”€β”€ MusicSortFilterButton.ts
   |  |  β”œβ”€β”€ MusicTastebuilderShelf.ts
   |  |  β”œβ”€β”€ MusicTastebuilderShelfThumbnail.ts
   |  |  β”œβ”€β”€ MusicThumbnail.ts
   |  |  β”œβ”€β”€ MusicTwoRowItem.ts
   |  |  β”œβ”€β”€ MusicVisualHeader.ts
   |  |  β”œβ”€β”€ NavigationEndpoint.ts
   |  |  β”œβ”€β”€ Notification.ts
   |  |  β”œβ”€β”€ PageHeader.ts
   |  |  β”œβ”€β”€ PageHeaderView.ts
   |  |  β”œβ”€β”€ PageIntroduction.ts
   |  |  β”œβ”€β”€ PivotButton.ts
   |  |  β”œβ”€β”€ PlayerAnnotationsExpanded.ts
   |  |  β”œβ”€β”€ PlayerCaptionsTracklist.ts
   |  |  β”œβ”€β”€ PlayerControlsOverlay.ts
   |  |  β”œβ”€β”€ PlayerErrorMessage.ts
   |  |  β”œβ”€β”€ PlayerLegacyDesktopYpcOffer.ts
   |  |  β”œβ”€β”€ PlayerLegacyDesktopYpcTrailer.ts
   |  |  β”œβ”€β”€ PlayerLiveStoryboardSpec.ts
   |  |  β”œβ”€β”€ PlayerMicroformat.ts
   |  |  β”œβ”€β”€ PlayerOverflow.ts
   |  |  β”œβ”€β”€ PlayerOverlay.ts
   |  |  β”œβ”€β”€ PlayerOverlayAutoplay.ts
   |  |  β”œβ”€β”€ PlayerStoryboardSpec.ts
   |  |  β”œβ”€β”€ Playlist.ts
   |  |  β”œβ”€β”€ PlaylistCustomThumbnail.ts
   |  |  β”œβ”€β”€ PlaylistHeader.ts
   |  |  β”œβ”€β”€ PlaylistInfoCardContent.ts
   |  |  β”œβ”€β”€ PlaylistMetadata.ts
   |  |  β”œβ”€β”€ PlaylistPanel.ts
   |  |  β”œβ”€β”€ PlaylistPanelVideo.ts
   |  |  β”œβ”€β”€ PlaylistPanelVideoWrapper.ts
   |  |  β”œβ”€β”€ PlaylistSidebar.ts
   |  |  β”œβ”€β”€ PlaylistSidebarPrimaryInfo.ts
   |  |  β”œβ”€β”€ PlaylistSidebarSecondaryInfo.ts
   |  |  β”œβ”€β”€ PlaylistVideo.ts
   |  |  β”œβ”€β”€ PlaylistVideoList.ts
   |  |  β”œβ”€β”€ PlaylistVideoThumbnail.ts
   |  |  β”œβ”€β”€ Poll.ts
   |  |  β”œβ”€β”€ Post.ts
   |  |  β”œβ”€β”€ PostMultiImage.ts
   |  |  β”œβ”€β”€ ProductList.ts
   |  |  β”œβ”€β”€ ProductListHeader.ts
   |  |  β”œβ”€β”€ ProductListItem.ts
   |  |  β”œβ”€β”€ ProfileColumn.ts
   |  |  β”œβ”€β”€ ProfileColumnStats.ts
   |  |  β”œβ”€β”€ ProfileColumnStatsEntry.ts
   |  |  β”œβ”€β”€ ProfileColumnUserInfo.ts
   |  |  β”œβ”€β”€ Quiz.ts
   |  |  β”œβ”€β”€ RecognitionShelf.ts
   |  |  β”œβ”€β”€ ReelItem.ts
   |  |  β”œβ”€β”€ ReelPlayerHeader.ts
   |  |  β”œβ”€β”€ ReelPlayerOverlay.ts
   |  |  β”œβ”€β”€ ReelShelf.ts
   |  |  β”œβ”€β”€ RelatedChipCloud.ts
   |  |  β”œβ”€β”€ RichGrid.ts
   |  |  β”œβ”€β”€ RichItem.ts
   |  |  β”œβ”€β”€ RichListHeader.ts
   |  |  β”œβ”€β”€ RichMetadata.ts
   |  |  β”œβ”€β”€ RichMetadataRow.ts
   |  |  β”œβ”€β”€ RichSection.ts
   |  |  β”œβ”€β”€ RichShelf.ts
   |  |  β”œβ”€β”€ SearchBox.ts
   |  |  β”œβ”€β”€ SearchFilter.ts
   |  |  β”œβ”€β”€ SearchFilterGroup.ts
   |  |  β”œβ”€β”€ SearchFilterOptionsDialog.ts
   |  |  β”œβ”€β”€ SearchHeader.ts
   |  |  β”œβ”€β”€ SearchRefinementCard.ts
   |  |  β”œβ”€β”€ SearchSubMenu.ts
   |  |  β”œβ”€β”€ SearchSuggestion.ts
   |  |  β”œβ”€β”€ SearchSuggestionsSection.ts
   |  |  β”œβ”€β”€ SecondarySearchContainer.ts
   |  |  β”œβ”€β”€ SectionList.ts
   |  |  β”œβ”€β”€ SegmentedLikeDislikeButton.ts
   |  |  β”œβ”€β”€ SegmentedLikeDislikeButtonView.ts
   |  |  β”œβ”€β”€ SettingBoolean.ts
   |  |  β”œβ”€β”€ SettingsCheckbox.ts
   |  |  β”œβ”€β”€ SettingsOptions.ts
   |  |  β”œβ”€β”€ SettingsSidebar.ts
   |  |  β”œβ”€β”€ SettingsSwitch.ts
   |  |  β”œβ”€β”€ SharedPost.ts
   |  |  β”œβ”€β”€ Shelf.ts
   |  |  β”œβ”€β”€ ShowCustomThumbnail.ts
   |  |  β”œβ”€β”€ ShowingResultsFor.ts
   |  |  β”œβ”€β”€ SimpleCardContent.ts
   |  |  β”œβ”€β”€ SimpleCardTeaser.ts
   |  |  β”œβ”€β”€ SimpleTextSection.ts
   |  |  β”œβ”€β”€ SingleActionEmergencySupport.ts
   |  |  β”œβ”€β”€ SingleColumnBrowseResults.ts
   |  |  β”œβ”€β”€ SingleColumnMusicWatchNextResults.ts
   |  |  β”œβ”€β”€ SingleHeroImage.ts
   |  |  β”œβ”€β”€ SlimOwner.ts
   |  |  β”œβ”€β”€ SlimVideoMetadata.ts
   |  |  β”œβ”€β”€ SortFilterHeader.ts
   |  |  β”œβ”€β”€ SortFilterSubMenu.ts
   |  |  β”œβ”€β”€ StructuredDescriptionContent.ts
   |  |  β”œβ”€β”€ StructuredDescriptionPlaylistLockup.ts
   |  |  β”œβ”€β”€ SubFeedOption.ts
   |  |  β”œβ”€β”€ SubFeedSelector.ts
   |  |  β”œβ”€β”€ SubscribeButton.ts
   |  |  β”œβ”€β”€ SubscriptionNotificationToggleButton.ts
   |  |  β”œβ”€β”€ Tab.ts
   |  |  β”œβ”€β”€ Tabbed.ts
   |  |  β”œβ”€β”€ TabbedSearchResults.ts
   |  |  β”œβ”€β”€ TextHeader.ts
   |  |  β”œβ”€β”€ ThumbnailBadgeView.ts
   |  |  β”œβ”€β”€ ThumbnailHoverOverlayView.ts
   |  |  β”œβ”€β”€ ThumbnailLandscapePortrait.ts
   |  |  β”œβ”€β”€ ThumbnailOverlayBadgeView.ts
   |  |  β”œβ”€β”€ ThumbnailOverlayBottomPanel.ts
   |  |  β”œβ”€β”€ ThumbnailOverlayEndorsement.ts
   |  |  β”œβ”€β”€ ThumbnailOverlayHoverText.ts
   |  |  β”œβ”€β”€ ThumbnailOverlayInlineUnplayable.ts
   |  |  β”œβ”€β”€ ThumbnailOverlayLoadingPreview.ts
   |  |  β”œβ”€β”€ ThumbnailOverlayNowPlaying.ts
   |  |  β”œβ”€β”€ ThumbnailOverlayPinking.ts
   |  |  β”œβ”€β”€ ThumbnailOverlayPlaybackStatus.ts
   |  |  β”œβ”€β”€ ThumbnailOverlayResumePlayback.ts
   |  |  β”œβ”€β”€ ThumbnailOverlaySidePanel.ts
   |  |  β”œβ”€β”€ ThumbnailOverlayTimeStatus.ts
   |  |  β”œβ”€β”€ ThumbnailOverlayToggleButton.ts
   |  |  β”œβ”€β”€ ThumbnailView.ts
   |  |  β”œβ”€β”€ TimedMarkerDecoration.ts
   |  |  β”œβ”€β”€ TitleAndButtonListHeader.ts
   |  |  β”œβ”€β”€ ToggleButton.ts
   |  |  β”œβ”€β”€ ToggleButtonView.ts
   |  |  β”œβ”€β”€ ToggleMenuServiceItem.ts
   |  |  β”œβ”€β”€ Tooltip.ts
   |  |  β”œβ”€β”€ TopicChannelDetails.ts
   |  |  β”œβ”€β”€ Transcript.ts
   |  |  β”œβ”€β”€ TranscriptFooter.ts
   |  |  β”œβ”€β”€ TranscriptSearchBox.ts
   |  |  β”œβ”€β”€ TranscriptSearchPanel.ts
   |  |  β”œβ”€β”€ TranscriptSectionHeader.ts
   |  |  β”œβ”€β”€ TranscriptSegment.ts
   |  |  β”œβ”€β”€ TranscriptSegmentList.ts
   |  |  β”œβ”€β”€ TwoColumnBrowseResults.ts
   |  |  β”œβ”€β”€ TwoColumnSearchResults.ts
   |  |  β”œβ”€β”€ TwoColumnWatchNextResults.ts
   |  |  β”œβ”€β”€ UniversalWatchCard.ts
   |  |  β”œβ”€β”€ UploadTimeFactoid.ts
   |  |  β”œβ”€β”€ UpsellDialog.ts
   |  |  β”œβ”€β”€ VerticalList.ts
   |  |  β”œβ”€β”€ VerticalWatchCardList.ts
   |  |  β”œβ”€β”€ Video.ts
   |  |  β”œβ”€β”€ VideoAttributeView.ts
   |  |  β”œβ”€β”€ VideoAttributesSectionView.ts
   |  |  β”œβ”€β”€ VideoCard.ts
   |  |  β”œβ”€β”€ VideoDescriptionCourseSection.ts
   |  |  β”œβ”€β”€ VideoDescriptionHeader.ts
   |  |  β”œβ”€β”€ VideoDescriptionInfocardsSection.ts
   |  |  β”œβ”€β”€ VideoDescriptionMusicSection.ts
   |  |  β”œβ”€β”€ VideoDescriptionTranscriptSection.ts
   |  |  β”œβ”€β”€ VideoInfoCardContent.ts
   |  |  β”œβ”€β”€ VideoOwner.ts
   |  |  β”œβ”€β”€ VideoPrimaryInfo.ts
   |  |  β”œβ”€β”€ VideoSecondaryInfo.ts
   |  |  β”œβ”€β”€ ViewCountFactoid.ts
   |  |  β”œβ”€β”€ WatchCardCompactVideo.ts
   |  |  β”œβ”€β”€ WatchCardHeroVideo.ts
   |  |  β”œβ”€β”€ WatchCardRichHeader.ts
   |  |  β”œβ”€β”€ WatchCardSectionSequence.ts
   |  |  β”œβ”€β”€ WatchNextEndScreen.ts
   |  |  β”œβ”€β”€ WatchNextTabbedResults.ts
   |  |  β”œβ”€β”€ YpcTrailer.ts
   |  |  β”œβ”€β”€ actions
   |  |  |  β”œβ”€β”€ AppendContinuationItemsAction.ts
   |  |  |  β”œβ”€β”€ OpenPopupAction.ts
   |  |  |  └── UpdateEngagementPanelAction.ts
   |  |  β”œβ”€β”€ analytics
   |  |  |  β”œβ”€β”€ AnalyticsMainAppKeyMetrics.ts
   |  |  |  β”œβ”€β”€ AnalyticsRoot.ts
   |  |  |  β”œβ”€β”€ AnalyticsShortsCarouselCard.ts
   |  |  |  β”œβ”€β”€ AnalyticsVideo.ts
   |  |  |  β”œβ”€β”€ AnalyticsVodCarouselCard.ts
   |  |  |  β”œβ”€β”€ CtaGoToCreatorStudio.ts
   |  |  |  β”œβ”€β”€ DataModelSection.ts
   |  |  |  └── StatRow.ts
   |  |  β”œβ”€β”€ comments
   |  |  |  β”œβ”€β”€ AuthorCommentBadge.ts
   |  |  |  β”œβ”€β”€ Comment.ts
   |  |  |  β”œβ”€β”€ CommentActionButtons.ts
   |  |  |  β”œβ”€β”€ CommentDialog.ts
   |  |  |  β”œβ”€β”€ CommentReplies.ts
   |  |  |  β”œβ”€β”€ CommentReplyDialog.ts
   |  |  |  β”œβ”€β”€ CommentSimplebox.ts
   |  |  |  β”œβ”€β”€ CommentThread.ts
   |  |  |  β”œβ”€β”€ CommentView.ts
   |  |  |  β”œβ”€β”€ CommentsEntryPointHeader.ts
   |  |  |  β”œβ”€β”€ CommentsEntryPointTeaser.ts
   |  |  |  β”œβ”€β”€ CommentsHeader.ts
   |  |  |  β”œβ”€β”€ CommentsSimplebox.ts
   |  |  |  β”œβ”€β”€ CreatorHeart.ts
   |  |  |  β”œβ”€β”€ EmojiPicker.ts
   |  |  |  β”œβ”€β”€ PdgCommentChip.ts
   |  |  |  └── SponsorCommentBadge.ts
   |  |  β”œβ”€β”€ livechat
   |  |  |  β”œβ”€β”€ AddBannerToLiveChatCommand.ts
   |  |  |  β”œβ”€β”€ AddChatItemAction.ts
   |  |  |  β”œβ”€β”€ AddLiveChatTickerItemAction.ts
   |  |  |  β”œβ”€β”€ DimChatItemAction.ts
   |  |  |  β”œβ”€β”€ LiveChatActionPanel.ts
   |  |  |  β”œβ”€β”€ MarkChatItemAsDeletedAction.ts
   |  |  |  β”œβ”€β”€ MarkChatItemsByAuthorAsDeletedAction.ts
   |  |  |  β”œβ”€β”€ RemoveBannerForLiveChatCommand.ts
   |  |  |  β”œβ”€β”€ RemoveChatItemAction.ts
   |  |  |  β”œβ”€β”€ RemoveChatItemByAuthorAction.ts
   |  |  |  β”œβ”€β”€ ReplaceChatItemAction.ts
   |  |  |  β”œβ”€β”€ ReplayChatItemAction.ts
   |  |  |  β”œβ”€β”€ ShowLiveChatActionPanelAction.ts
   |  |  |  β”œβ”€β”€ ShowLiveChatDialogAction.ts
   |  |  |  β”œβ”€β”€ ShowLiveChatTooltipCommand.ts
   |  |  |  β”œβ”€β”€ UpdateDateTextAction.ts
   |  |  |  β”œβ”€β”€ UpdateDescriptionAction.ts
   |  |  |  β”œβ”€β”€ UpdateLiveChatPollAction.ts
   |  |  |  β”œβ”€β”€ UpdateTitleAction.ts
   |  |  |  β”œβ”€β”€ UpdateToggleButtonTextAction.ts
   |  |  |  β”œβ”€β”€ UpdateViewershipAction.ts
   |  |  |  └── items
   |  |  |     β”œβ”€β”€ LiveChatAutoModMessage.ts
   |  |  |     β”œβ”€β”€ LiveChatBanner.ts
   |  |  |     β”œβ”€β”€ LiveChatBannerHeader.ts
   |  |  |     β”œβ”€β”€ LiveChatBannerPoll.ts
   |  |  |     β”œβ”€β”€ LiveChatMembershipItem.ts
   |  |  |     β”œβ”€β”€ LiveChatPaidMessage.ts
   |  |  |     β”œβ”€β”€ LiveChatPaidSticker.ts
   |  |  |     β”œβ”€β”€ LiveChatPlaceholderItem.ts
   |  |  |     β”œβ”€β”€ LiveChatProductItem.ts
   |  |  |     β”œβ”€β”€ LiveChatRestrictedParticipation.ts
   |  |  |     β”œβ”€β”€ LiveChatTextMessage.ts
   |  |  |     β”œβ”€β”€ LiveChatTickerPaidMessageItem.ts
   |  |  |     β”œβ”€β”€ LiveChatTickerPaidStickerItem.ts
   |  |  |     β”œβ”€β”€ LiveChatTickerSponsorItem.ts
   |  |  |     β”œβ”€β”€ LiveChatViewerEngagementMessage.ts
   |  |  |     └── PollHeader.ts
   |  |  β”œβ”€β”€ menus
   |  |  |  β”œβ”€β”€ Menu.ts
   |  |  |  β”œβ”€β”€ MenuNavigationItem.ts
   |  |  |  β”œβ”€β”€ MenuPopup.ts
   |  |  |  β”œβ”€β”€ MenuServiceItem.ts
   |  |  |  β”œβ”€β”€ MenuServiceItemDownload.ts
   |  |  |  β”œβ”€β”€ MultiPageMenu.ts
   |  |  |  β”œβ”€β”€ MultiPageMenuNotificationSection.ts
   |  |  |  β”œβ”€β”€ MusicMenuItemDivider.ts
   |  |  |  β”œβ”€β”€ MusicMultiSelectMenu.ts
   |  |  |  β”œβ”€β”€ MusicMultiSelectMenuItem.ts
   |  |  |  └── SimpleMenuHeader.ts
   |  |  β”œβ”€β”€ misc
   |  |  |  β”œβ”€β”€ Author.ts
   |  |  |  β”œβ”€β”€ ChildElement.ts
   |  |  |  β”œβ”€β”€ EmojiRun.ts
   |  |  |  β”œβ”€β”€ Format.ts
   |  |  |  β”œβ”€β”€ Text.ts
   |  |  |  β”œβ”€β”€ TextRun.ts
   |  |  |  β”œβ”€β”€ Thumbnail.ts
   |  |  |  └── VideoDetails.ts
   |  |  └── ytkids
   |  |     β”œβ”€β”€ AnchoredSection.ts
   |  |     β”œβ”€β”€ KidsBlocklistPicker.ts
   |  |     β”œβ”€β”€ KidsBlocklistPickerItem.ts
   |  |     β”œβ”€β”€ KidsCategoriesHeader.ts
   |  |     β”œβ”€β”€ KidsCategoryTab.ts
   |  |     └── KidsHomeScreen.ts
   |  β”œβ”€β”€ continuations.ts
   |  β”œβ”€β”€ generator.ts
   |  β”œβ”€β”€ helpers.ts
   |  β”œβ”€β”€ index.ts
   |  β”œβ”€β”€ misc.ts
   |  β”œβ”€β”€ nodes.ts
   |  β”œβ”€β”€ parser.ts
   |  β”œβ”€β”€ types
   |  |  β”œβ”€β”€ ParsedResponse.ts
   |  |  β”œβ”€β”€ RawResponse.ts
   |  |  └── index.ts
   |  β”œβ”€β”€ youtube
   |  |  β”œβ”€β”€ AccountInfo.ts
   |  |  β”œβ”€β”€ Analytics.ts
   |  |  β”œβ”€β”€ Channel.ts
   |  |  β”œβ”€β”€ Comments.ts
   |  |  β”œβ”€β”€ Guide.ts
   |  |  β”œβ”€β”€ HashtagFeed.ts
   |  |  β”œβ”€β”€ History.ts
   |  |  β”œβ”€β”€ HomeFeed.ts
   |  |  β”œβ”€β”€ ItemMenu.ts
   |  |  β”œβ”€β”€ Library.ts
   |  |  β”œβ”€β”€ LiveChat.ts
   |  |  β”œβ”€β”€ NotificationsMenu.ts
   |  |  β”œβ”€β”€ Playlist.ts
   |  |  β”œβ”€β”€ Search.ts
   |  |  β”œβ”€β”€ Settings.ts
   |  |  β”œβ”€β”€ SmoothedQueue.ts
   |  |  β”œβ”€β”€ TimeWatched.ts
   |  |  β”œβ”€β”€ TranscriptInfo.ts
   |  |  β”œβ”€β”€ VideoInfo.ts
   |  |  └── index.ts
   |  β”œβ”€β”€ ytkids
   |  |  β”œβ”€β”€ Channel.ts
   |  |  β”œβ”€β”€ HomeFeed.ts
   |  |  β”œβ”€β”€ Search.ts
   |  |  β”œβ”€β”€ VideoInfo.ts
   |  |  └── index.ts
   |  β”œβ”€β”€ ytmusic
   |  |  β”œβ”€β”€ Album.ts
   |  |  β”œβ”€β”€ Artist.ts
   |  |  β”œβ”€β”€ Explore.ts
   |  |  β”œβ”€β”€ HomeFeed.ts
   |  |  β”œβ”€β”€ Library.ts
   |  |  β”œβ”€β”€ Playlist.ts
   |  |  β”œβ”€β”€ Recap.ts
   |  |  β”œβ”€β”€ Search.ts
   |  |  β”œβ”€β”€ TrackInfo.ts
   |  |  └── index.ts
   |  └── ytshorts
   |     β”œβ”€β”€ ShortFormVideoInfo.ts
   |     └── index.ts
   β”œβ”€β”€ platform
   |  β”œβ”€β”€ README.md
   |  β”œβ”€β”€ cf-worker.ts
   |  β”œβ”€β”€ deno.ts
   |  β”œβ”€β”€ jsruntime/jinter.ts
   |  β”œβ”€β”€ lib.ts
   |  β”œβ”€β”€ node.ts
   |  β”œβ”€β”€ polyfills
   |  |  β”œβ”€β”€ node-custom-event.ts
   |  |  └── web-crypto.ts
   |  β”œβ”€β”€ react-native.md
   |  β”œβ”€β”€ react-native.ts
   |  └── web.ts
   β”œβ”€β”€ proto
   |  β”œβ”€β”€ generated
   |  |  β”œβ”€β”€ index.ts
   |  |  β”œβ”€β”€ messages
   |  |  |  β”œβ”€β”€ index.ts
   |  |  |  └── youtube
   |  |  |     β”œβ”€β”€ (ChannelAnalytics)
   |  |  |     |  β”œβ”€β”€ Params.ts
   |  |  |     |  └── index.ts
   |  |  |     β”œβ”€β”€ (CreateCommentParams)
   |  |  |     |  β”œβ”€β”€ Params.ts
   |  |  |     |  └── index.ts
   |  |  |     β”œβ”€β”€ (GetCommentsSectionParams)
   |  |  |     |  β”œβ”€β”€ (Params)
   |  |  |     |  |  β”œβ”€β”€ (RepliesOptions)
   |  |  |     |  |  |  β”œβ”€β”€ UnkOpts.ts
   |  |  |     |  |  |  └── index.ts
   |  |  |     |  |  β”œβ”€β”€ Options.ts
   |  |  |     |  |  β”œβ”€β”€ RepliesOptions.ts
   |  |  |     |  |  └── index.ts
   |  |  |     |  β”œβ”€β”€ Context.ts
   |  |  |     |  β”œβ”€β”€ Params.ts
   |  |  |     |  └── index.ts
   |  |  |     β”œβ”€β”€ (Hashtag)
   |  |  |     |  β”œβ”€β”€ Params.ts
   |  |  |     |  └── index.ts
   |  |  |     β”œβ”€β”€ (InnertubePayload)
   |  |  |     |  β”œβ”€β”€ (Context)
   |  |  |     |  |  β”œβ”€β”€ Client.ts
   |  |  |     |  |  └── index.ts
   |  |  |     |  β”œβ”€β”€ (VideoThumbnail)
   |  |  |     |  |  β”œβ”€β”€ Thumbnail.ts
   |  |  |     |  |  └── index.ts
   |  |  |     |  β”œβ”€β”€ AgeRestricted.ts
   |  |  |     |  β”œβ”€β”€ Category.ts
   |  |  |     |  β”œβ”€β”€ Context.ts
   |  |  |     |  β”œβ”€β”€ Description.ts
   |  |  |     |  β”œβ”€β”€ License.ts
   |  |  |     |  β”œβ”€β”€ MadeForKids.ts
   |  |  |     |  β”œβ”€β”€ Privacy.ts
   |  |  |     |  β”œβ”€β”€ Tags.ts
   |  |  |     |  β”œβ”€β”€ Title.ts
   |  |  |     |  β”œβ”€β”€ VideoThumbnail.ts
   |  |  |     |  └── index.ts
   |  |  |     β”œβ”€β”€ (LiveMessageParams)
   |  |  |     |  β”œβ”€β”€ (Params)
   |  |  |     |  |  β”œβ”€β”€ Ids.ts
   |  |  |     |  |  └── index.ts
   |  |  |     |  β”œβ”€β”€ Params.ts
   |  |  |     |  └── index.ts
   |  |  |     β”œβ”€β”€ (MusicSearchFilter)
   |  |  |     |  β”œβ”€β”€ (Filters)
   |  |  |     |  |  β”œβ”€β”€ Type.ts
   |  |  |     |  |  └── index.ts
   |  |  |     |  β”œβ”€β”€ Filters.ts
   |  |  |     |  └── index.ts
   |  |  |     β”œβ”€β”€ (NotificationPreferences)
   |  |  |     |  β”œβ”€β”€ Preference.ts
   |  |  |     |  └── index.ts
   |  |  |     β”œβ”€β”€ (PeformCommentActionParams)
   |  |  |     |  β”œβ”€β”€ (TranslateCommentParams)
   |  |  |     |  |  β”œβ”€β”€ (Params)
   |  |  |     |  |  |  β”œβ”€β”€ Comment.ts
   |  |  |     |  |  |  └── index.ts
   |  |  |     |  |  β”œβ”€β”€ Params.ts
   |  |  |     |  |  └── index.ts
   |  |  |     |  β”œβ”€β”€ TranslateCommentParams.ts
   |  |  |     |  └── index.ts
   |  |  |     β”œβ”€β”€ (ReelSequence)
   |  |  |     |  β”œβ”€β”€ Params.ts
   |  |  |     |  └── index.ts
   |  |  |     β”œβ”€β”€ (SearchFilter)
   |  |  |     |  β”œβ”€β”€ Filters.ts
   |  |  |     |  └── index.ts
   |  |  |     β”œβ”€β”€ (ShortsParam)
   |  |  |     |  β”œβ”€β”€ Field1.ts
   |  |  |     |  └── index.ts
   |  |  |     β”œβ”€β”€ (SoundInfoParams)
   |  |  |     |  β”œβ”€β”€ (Sound)
   |  |  |     |  |  β”œβ”€β”€ (Params)
   |  |  |     |  |  |  β”œβ”€β”€ Ids.ts
   |  |  |     |  |  |  └── index.ts
   |  |  |     |  |  β”œβ”€β”€ Params.ts
   |  |  |     |  |  └── index.ts
   |  |  |     |  β”œβ”€β”€ Sound.ts
   |  |  |     |  └── index.ts
   |  |  |     β”œβ”€β”€ ChannelAnalytics.ts
   |  |  |     β”œβ”€β”€ CreateCommentParams.ts
   |  |  |     β”œβ”€β”€ GetCommentsSectionParams.ts
   |  |  |     β”œβ”€β”€ Hashtag.ts
   |  |  |     β”œβ”€β”€ InnertubePayload.ts
   |  |  |     β”œβ”€β”€ LiveMessageParams.ts
   |  |  |     β”œβ”€β”€ MusicSearchFilter.ts
   |  |  |     β”œβ”€β”€ NotificationPreferences.ts
   |  |  |     β”œβ”€β”€ PeformCommentActionParams.ts
   |  |  |     β”œβ”€β”€ ReelSequence.ts
   |  |  |     β”œβ”€β”€ SearchFilter.ts
   |  |  |     β”œβ”€β”€ ShortsParam.ts
   |  |  |     β”œβ”€β”€ SoundInfoParams.ts
   |  |  |     β”œβ”€β”€ VisitorData.ts
   |  |  |     └── index.ts
   |  |  └── runtime
   |  |     β”œβ”€β”€ Long.ts
   |  |     β”œβ”€β”€ array.ts
   |  |     β”œβ”€β”€ async
   |  |     |  β”œβ”€β”€ async-generator.ts
   |  |     |  β”œβ”€β”€ event-buffer.ts
   |  |     |  β”œβ”€β”€ event-emitter.ts
   |  |     |  β”œβ”€β”€ observer.ts
   |  |     |  └── wait.ts
   |  |     β”œβ”€β”€ base64.ts
   |  |     β”œβ”€β”€ client-devtools.ts
   |  |     β”œβ”€β”€ json/scalar.ts
   |  |     β”œβ”€β”€ rpc.ts
   |  |     β”œβ”€β”€ scalar.ts
   |  |     └── wire
   |  |        β”œβ”€β”€ deserialize.ts
   |  |        β”œβ”€β”€ index.ts
   |  |        β”œβ”€β”€ scalar.ts
   |  |        β”œβ”€β”€ serialize.ts
   |  |        β”œβ”€β”€ varint.ts
   |  |        └── zigzag.ts
   |  └── index.ts
   β”œβ”€β”€ types
   |  β”œβ”€β”€ Cache.ts
   |  β”œβ”€β”€ Clients.ts
   |  β”œβ”€β”€ DashOptions.ts
   |  β”œβ”€β”€ Endpoints.ts
   |  β”œβ”€β”€ FormatUtils.ts
   |  β”œβ”€β”€ PlatformShim.ts
   |  β”œβ”€β”€ StreamingInfoOptions.ts
   |  └── index.ts
   └── utils
      β”œβ”€β”€ Cache.ts
      β”œβ”€β”€ Constants.ts
      β”œβ”€β”€ DashManifest.tsx
      β”œβ”€β”€ DashUtils.ts
      β”œβ”€β”€ EventEmitterLike.ts
      β”œβ”€β”€ FormatUtils.ts
      β”œβ”€β”€ HTTPClient.ts
      β”œβ”€β”€ LZW.ts
      β”œβ”€β”€ Log.ts
      β”œβ”€β”€ StreamingInfo.ts
      β”œβ”€β”€ UMP.ts
      β”œβ”€β”€ Utils.ts
      β”œβ”€β”€ index.ts
      └── user-agents.ts

LuanRT/YouTube.js/blob/main/CHANGELOG.md:

# Changelog

## [10.3.0](https://github.com/LuanRT/YouTube.js/compare/v10.2.0...v10.3.0) (2024-08-01)


### Features

* **parser:** Add `EomSettingsDisclaimer` node ([#703](https://github.com/LuanRT/YouTube.js/issues/703)) ([a9bf225](https://github.com/LuanRT/YouTube.js/commit/a9bf225a62108e47a50316235a83a814c682d745))
* **PlaylistManager:** Add ability to remove videos by set ID ([#715](https://github.com/LuanRT/YouTube.js/issues/715)) ([d85fbc5](https://github.com/LuanRT/YouTube.js/commit/d85fbc56cf0fd7367b182ae36e65c1701bc5e62d))


### Bug Fixes

* **HTTPClient:** Adjust more context fields for the iOS client ([#705](https://github.com/LuanRT/YouTube.js/issues/705)) ([3153375](https://github.com/LuanRT/YouTube.js/commit/3153375bcaa6c03afba9da8474e6a9d37471ed29))

## [10.2.0](https://github.com/LuanRT/YouTube.js/compare/v10.1.0...v10.2.0) (2024-07-25)


### Features

* **Format:** Add `is_secondary` for detecting secondary audio tracks ([#697](https://github.com/LuanRT/YouTube.js/issues/697)) ([a352dde](https://github.com/LuanRT/YouTube.js/commit/a352ddeb9db001e99f49025048ad0942d84f1b3e))
* **parser:** add classdata to unhandled parse errors ([#691](https://github.com/LuanRT/YouTube.js/issues/691)) ([090539b](https://github.com/LuanRT/YouTube.js/commit/090539b28f9bc3387d01e37325ba5741b33b1765))
* **proto:** Add `comment_id` to commentSectionParams ([#693](https://github.com/LuanRT/YouTube.js/issues/693)) ([a5f6209](https://github.com/LuanRT/YouTube.js/commit/a5f62093a18705fc822abd86beaa81788b6535ce))


### Bug Fixes

* **parser:** ignore MiniGameCardView node ([#692](https://github.com/LuanRT/YouTube.js/issues/692)) ([6d0bc89](https://github.com/LuanRT/YouTube.js/commit/6d0bc89be18f27a8ce74517f5cab5020d6790328))
* **parser:** ThumbnailView background color ([#686](https://github.com/LuanRT/YouTube.js/issues/686)) ([0f8f92a](https://github.com/LuanRT/YouTube.js/commit/0f8f92a28a5b6143e890626b22ce570730a0cf09))
* **Player:** Bump cache version ([#702](https://github.com/LuanRT/YouTube.js/issues/702)) ([6765f4e](https://github.com/LuanRT/YouTube.js/commit/6765f4e0d791c657fc7411e9cdd2c0f9284e9982))
* **Player:** Fix extracting the n-token decipher algorithm again ([#701](https://github.com/LuanRT/YouTube.js/issues/701)) ([3048f70](https://github.com/LuanRT/YouTube.js/commit/3048f70f60756884bd7b591d770f7b6343cfa259))

## [10.1.0](https://github.com/LuanRT/YouTube.js/compare/v10.0.0...v10.1.0) (2024-07-10)


### Features

* **Session:** Add `configInfo` to InnerTube context ([5a8fd3a](https://github.com/LuanRT/YouTube.js/commit/5a8fd3ad37bce1decad28ec3727453ddd430a561))
* **toDash:** Add option to include WebVTT or TTML captions ([#673](https://github.com/LuanRT/YouTube.js/issues/673)) ([bd9f6ac](https://github.com/LuanRT/YouTube.js/commit/bd9f6ac64ca9ba96e856aabe5fcc175fd9c294dc))
* **toDash:** Add the "dub" role to translated captions ([#677](https://github.com/LuanRT/YouTube.js/issues/677)) ([858cdd1](https://github.com/LuanRT/YouTube.js/commit/858cdd197cb2bb1e1d7a7285966cb56043ad8961))


### Bug Fixes

* **FormatUtils:** Throw an error if download requests fails ([a19511d](https://github.com/LuanRT/YouTube.js/commit/a19511de24bb82007aab072844efe64bbb8698da))
* **InfoPanelContent:** Update InfoPanelContent node to also support `paragraphs` ([4cbaa79](https://github.com/LuanRT/YouTube.js/commit/4cbaa7983f35a82b9907197769672ac3b300bfbe))
* **Player:** Fix extracting the n-token decipher algorithm ([#682](https://github.com/LuanRT/YouTube.js/issues/682)) ([142a7d0](https://github.com/LuanRT/YouTube.js/commit/142a7d042885188605bdc0655d3733502d1e20fa))
* **proto:** Update `Context` message ([62ac2f6](https://github.com/LuanRT/YouTube.js/commit/62ac2f6f32d35fec3c31b5f5d556bd4569fa49f9)), closes [#681](https://github.com/LuanRT/YouTube.js/issues/681)
* **Session:** Round UTC offset minutes ([84f90aa](https://github.com/LuanRT/YouTube.js/commit/84f90aaf2908ecacb9dfb6ce5497c4c4d14a72c3))
* **toDash:** Fix image representations not being spec compliant ([#672](https://github.com/LuanRT/YouTube.js/issues/672)) ([e5aab9a](https://github.com/LuanRT/YouTube.js/commit/e5aab9a9b35f0752cd5ca50bfa25936dce4718c6))
* **YTMusic:** Add support for new header layouts ([14c3a06](https://github.com/LuanRT/YouTube.js/commit/14c3a06d402989e98a9d32c79b2dc26f74fb0219))

## [10.0.0](https://github.com/LuanRT/YouTube.js/compare/v9.4.0...v10.0.0) (2024-06-09)


### ⚠ BREAKING CHANGES

* **Innertube#getPlaylists:** Return a `Feed` instance instead of items
* **OAuth2:** Rewrite auth module ([#661](https://github.com/LuanRT/YouTube.js/issues/661))

### Features

* **Format:** Add `is_drc` ([#656](https://github.com/LuanRT/YouTube.js/issues/656)) ([6bb2086](https://github.com/LuanRT/YouTube.js/commit/6bb2086875d089f47c5f86ce94db9e32cb051319))
* **Platform:** Add support for `react-native` platform ([#593](https://github.com/LuanRT/YouTube.js/issues/593)) ([2980a60](https://github.com/LuanRT/YouTube.js/commit/2980a608b67f18416d7f73f1bdbcf4b897307b26))
* **Session:** Add `enable_session_cache` option ([#664](https://github.com/LuanRT/YouTube.js/issues/664)) ([7953296](https://github.com/LuanRT/YouTube.js/commit/795329658033652625d2d61b275ccf703573a437))
* **toDash:** Add support for stable volume/DRC ([#662](https://github.com/LuanRT/YouTube.js/issues/662)) ([031ffb6](https://github.com/LuanRT/YouTube.js/commit/031ffb696e3b7e160779e8b55a49b0cfa9f95620))


### Bug Fixes

* **ButtonView:** Rename `type` property to `button_type` ([15f3b5f](https://github.com/LuanRT/YouTube.js/commit/15f3b5fdba17f11cddada168de268546875e48f9))
* **Cache:** Use `TextEncoder` to encode compressed data ([384b80e](https://github.com/LuanRT/YouTube.js/commit/384b80ee41d7547a00d8dc17c50c8542629264b5))
* **FlexibleActionsView:** Update actions array type to include `ToggleButtonView` ([040a091](https://github.com/LuanRT/YouTube.js/commit/040a09163903b914f546d5083dbfdeab7175b24c))
* **InfoPanelContainer:** Use new attributed text prop ([5cdb9e1](https://github.com/LuanRT/YouTube.js/commit/5cdb9e1e2fa4ad5abdb3659bb35d0b3edc60123c))
* **ItemSection:** Fix `target_id` not being set because of a typo. ([#655](https://github.com/LuanRT/YouTube.js/issues/655)) ([8106654](https://github.com/LuanRT/YouTube.js/commit/810665407e91b2890a8e555fd759d67ccd800379))
* **MusicResponsiveHeader:** Add `Text` import ([583fd9f](https://github.com/LuanRT/YouTube.js/commit/583fd9f8d70735d071b34bd1d68faa62eeac593a))


### Performance Improvements

* **general:** Add session cache and LZW compression ([#663](https://github.com/LuanRT/YouTube.js/issues/663)) ([cf29664](https://github.com/LuanRT/YouTube.js/commit/cf29664d376ff792602400ef9a4ac301c676735c))


### Code Refactoring

* **Innertube#getPlaylists:** Return a `Feed` instance instead of items ([7660450](https://github.com/LuanRT/YouTube.js/commit/766045049d7154866e6fe32f6d965025d736d77d))
* **OAuth2:** Rewrite auth module ([#661](https://github.com/LuanRT/YouTube.js/issues/661)) ([b6ce5f9](https://github.com/LuanRT/YouTube.js/commit/b6ce5f903fa2285cb381d73aedf02cc5e2712478))

## [9.4.0](https://github.com/LuanRT/YouTube.js/compare/v9.3.0...v9.4.0) (2024-04-29)


### Features

* **Format:** Add `projection_type` and `stereo_layout` ([#643](https://github.com/LuanRT/YouTube.js/issues/643)) ([064436c](https://github.com/LuanRT/YouTube.js/commit/064436cef30e892d8f569d4f7b146557fd72b09f))
* **Format:** Add `spatial_audio_type` ([#647](https://github.com/LuanRT/YouTube.js/issues/647)) ([0ba8c54](https://github.com/LuanRT/YouTube.js/commit/0ba8c54257b068d7e4518c982396acb42f1dd41d))
* **Parser:** Add `MusicResponsiveHeader` node ([ea82bea](https://github.com/LuanRT/YouTube.js/commit/ea82beaa10f6c877d6dd3102e10f6ae382560e0f))

## [9.3.0](https://github.com/LuanRT/YouTube.js/compare/v9.2.1...v9.3.0) (2024-04-11)


### Features

* **CommentView:** Implement comment interaction methods ([1c08bfe](https://github.com/LuanRT/YouTube.js/commit/1c08bfe113804c69fbc4e49b42442d9a63d73be6))


### Bug Fixes

* **CommentThread:** Replies not being parsed correctly ([66e34f9](https://github.com/LuanRT/YouTube.js/commit/66e34f9388429a2088d5c5835d19eebdc881c957))

## [9.2.1](https://github.com/LuanRT/YouTube.js/compare/v9.2.0...v9.2.1) (2024-04-09)


### Bug Fixes

* **toDash:** Add missing transfer characteristics for h264 streams ([#631](https://github.com/LuanRT/YouTube.js/issues/631)) ([0107049](https://github.com/LuanRT/YouTube.js/commit/010704929fa4b737f68a5a1f10bf0b166cfbf905))

## [9.2.0](https://github.com/LuanRT/YouTube.js/compare/v9.1.0...v9.2.0) (2024-03-31)


### Features

* add support of cloudflare workers ([#596](https://github.com/LuanRT/YouTube.js/issues/596)) ([2029aec](https://github.com/LuanRT/YouTube.js/commit/2029aec90de3c0fdb022094d7b704a2feed4133b))
* **parser:** Support CommentView nodes ([#614](https://github.com/LuanRT/YouTube.js/issues/614)) ([900f557](https://github.com/LuanRT/YouTube.js/commit/900f5572021d348e7012909f2080e52eac06adae))
* **parser:** Support LockupView and it's child nodes ([#609](https://github.com/LuanRT/YouTube.js/issues/609)) ([7ca2a0c](https://github.com/LuanRT/YouTube.js/commit/7ca2a0c3e43ebd4b9443e69b7432f302b09e9c7a))
* **Text:** Support formatting and emojis in `fromAttributed` ([#615](https://github.com/LuanRT/YouTube.js/issues/615)) ([e6f1f07](https://github.com/LuanRT/YouTube.js/commit/e6f1f078a828f8ea5db1fe7aec9f677bc53694e3))


### Bug Fixes

* **Cache:** handle the value read from the db correctly according to its type ([#620](https://github.com/LuanRT/YouTube.js/issues/620)) ([3170659](https://github.com/LuanRT/YouTube.js/commit/317065988007c860bf6173b0ac3c3d685ed81d20))
* **PlayerEndpoint:** Workaround for "The following content is not available on this app" (Android) ([#624](https://github.com/LuanRT/YouTube.js/issues/624)) ([d589365](https://github.com/LuanRT/YouTube.js/commit/d589365ea27f540ff36e33a65362c932cd28c274))

## [9.1.0](https://github.com/LuanRT/YouTube.js/compare/v9.0.2...v9.1.0) (2024-02-23)


### Features

* **Format:** Support caption tracks in adaptive formats ([#598](https://github.com/LuanRT/YouTube.js/issues/598)) ([bff65f8](https://github.com/LuanRT/YouTube.js/commit/bff65f8889c32813ec05bd187f3a4386fc6127c0))


### Bug Fixes

* **Playlist:** `items` getter failing if a playlist contains Shorts ([89fa3b2](https://github.com/LuanRT/YouTube.js/commit/89fa3b27a839d98aaf8bd70dd75220ee309c2bea))
* **Session:** Don't try to extract api version from service worker ([2068dfb](https://github.com/LuanRT/YouTube.js/commit/2068dfb73eefc0e40157421d4e5b4096c0c8429c))

## [9.0.2](https://github.com/LuanRT/YouTube.js/compare/v9.0.1...v9.0.2) (2024-01-31)


### Bug Fixes

* **VideoInfo:** Fix error because of typo in getWatchNextContinuation ([#590](https://github.com/LuanRT/YouTube.js/issues/590)) ([b21eb9f](https://github.com/LuanRT/YouTube.js/commit/b21eb9f33d956e130bac98712384125ae04ace47))

## [9.0.1](https://github.com/LuanRT/YouTube.js/compare/v9.0.0...v9.0.1) (2024-01-26)


### Bug Fixes

* **build:** Circular imports causing issues with webpack ([81dd5d3](https://github.com/LuanRT/YouTube.js/commit/81dd5d3288771132e7fb904b620e58277f639ccc))

## [9.0.0](https://github.com/LuanRT/YouTube.js/compare/v8.2.0...v9.0.0) (2024-01-25)


### ⚠ BREAKING CHANGES

* **toDash:** Add support for generating manifests for Post Live DVR videos ([#580](https://github.com/LuanRT/YouTube.js/issues/580))

### Features

* **Channel:** Support getting about with PageHeader ([#581](https://github.com/LuanRT/YouTube.js/issues/581)) ([2e710dc](https://github.com/LuanRT/YouTube.js/commit/2e710dc9f7e206627f189df19be17009b270bc8b))
* **Channel:** Support PageHeader being used on user channels ([#577](https://github.com/LuanRT/YouTube.js/issues/577)) ([6082b4a](https://github.com/LuanRT/YouTube.js/commit/6082b4a52ee07a622735e6e9128a0531a5ae3bfb))
* **Format:** Add `max_dvr_duration_sec` and `target_duration_dec` ([#570](https://github.com/LuanRT/YouTube.js/issues/570)) ([586bb5f](https://github.com/LuanRT/YouTube.js/commit/586bb5f1398d68bfabfb9449f527e53c398c3767))
* **parser:** Add `ImageBannerView` ([#583](https://github.com/LuanRT/YouTube.js/issues/583)) ([2073aa9](https://github.com/LuanRT/YouTube.js/commit/2073aa910a0e441a8ec1a9ba0434051ec0e2e6a9))
* **toDash:** Add support for generating manifests for Post Live DVR videos ([#580](https://github.com/LuanRT/YouTube.js/issues/580)) ([6dd03e1](https://github.com/LuanRT/YouTube.js/commit/6dd03e1658036c2fba0696de81033b5e16abb379))
* **VideoDetails:** Add `is_live_dvr_enabled`, `is_low_latency_live_stream` and `live_chunk_readahead` ([#569](https://github.com/LuanRT/YouTube.js/issues/569)) ([254f779](https://github.com/LuanRT/YouTube.js/commit/254f77944fcd398cc19cb62b82b0fdfbe6ed70ed))
* **VideoInfo:** Add live stream `end_timestamp` ([#571](https://github.com/LuanRT/YouTube.js/issues/571)) ([562e6a2](https://github.com/LuanRT/YouTube.js/commit/562e6a20f06ef5302af427861355215630d91edc))


### Bug Fixes

* **DecoratedAvatarView:** Fix parsing and optional properties ([#584](https://github.com/LuanRT/YouTube.js/issues/584)) ([fed3512](https://github.com/LuanRT/YouTube.js/commit/fed3512461277b7fc41e26c770e2bd3d4a7d5eb5))
* **PlayerCaptionTracklist:** Fix `captions_tracks[].kind` type ([#586](https://github.com/LuanRT/YouTube.js/issues/586)) ([7fbc37f](https://github.com/LuanRT/YouTube.js/commit/7fbc37f9d1c109e448085d5736326dce82ca2c9a))
* **proto:** Fix visitor data base64url decoding ([#576](https://github.com/LuanRT/YouTube.js/issues/576)) ([3980f97](https://github.com/LuanRT/YouTube.js/commit/3980f97b8fca05f95cda1ab347db9402c55b8b3c))
* **toDash:** Add missing transfer characteristics for h264 streams ([#573](https://github.com/LuanRT/YouTube.js/issues/573)) ([59f4cfb](https://github.com/LuanRT/YouTube.js/commit/59f4cfb4db6184d47f0a6634832055e9ce71f644))

## [8.2.0](https://github.com/LuanRT/YouTube.js/compare/v8.1.0...v8.2.0) (2024-01-08)


### Features

* **OAuth:** Allow passing custom client identity ([#566](https://github.com/LuanRT/YouTube.js/issues/566)) ([7ffd0fc](https://github.com/LuanRT/YouTube.js/commit/7ffd0fc25edef99a938e7986b1c74af05b8f954e))


### Bug Fixes

* **Parser:** Add `SortFilterHeader` ([#563](https://github.com/LuanRT/YouTube.js/issues/563)) ([8f07e49](https://github.com/LuanRT/YouTube.js/commit/8f07e49512c59eb72debc80a9d9623ca62330858))

## [8.1.0](https://github.com/LuanRT/YouTube.js/compare/v8.0.0...v8.1.0) (2023-12-27)


### Features

* **generator:** add support for arrays ([#556](https://github.com/LuanRT/YouTube.js/issues/556)) ([e4f2a00](https://github.com/LuanRT/YouTube.js/commit/e4f2a00c843fe453cc7904f79e35597cc6e2e619))
* **generator:** Add support for generating view models ([#550](https://github.com/LuanRT/YouTube.js/issues/550)) ([f938c34](https://github.com/LuanRT/YouTube.js/commit/f938c34ee81186774096b3d24d06250211ce2851))
* **MediaInfo:** Parse player config ([5c9c231](https://github.com/LuanRT/YouTube.js/commit/5c9c231cc2f17c49da03daa8262043b985320e9a))
* **parser:** Support new like and dislike nodes ([#557](https://github.com/LuanRT/YouTube.js/issues/557)) ([fcd3044](https://github.com/LuanRT/YouTube.js/commit/fcd30449821763e9b5b57718dd02eff15d964d2b))
* **Thumbnail:** Support `sources` in `Thumbnail.fromResponse` ([#552](https://github.com/LuanRT/YouTube.js/issues/552)) ([48a5d4e](https://github.com/LuanRT/YouTube.js/commit/48a5d4e7c37b76f8980f9b68e8815aef7a6d91ab))
* **YouTube:** Add FEchannels feed ([#560](https://github.com/LuanRT/YouTube.js/issues/560)) ([14578ac](https://github.com/LuanRT/YouTube.js/commit/14578ac96af4b8bee652cce87d043173de964113))


### Bug Fixes

* **Format:** Extract correct audio language from captions ([#553](https://github.com/LuanRT/YouTube.js/issues/553)) ([5c83e99](https://github.com/LuanRT/YouTube.js/commit/5c83e999dfa00386d18369f42aa9aa10123ba578))
* **generator:** Output Parser.parseItem() calls with one valid type, without the array ([#551](https://github.com/LuanRT/YouTube.js/issues/551)) ([bd487f8](https://github.com/LuanRT/YouTube.js/commit/bd487f8befe7f62022c61ff3aae7f487104e81eb))
* **VideoInfo:** Restore `like`, `dislike` & `removeRating` methods ([9c503f4](https://github.com/LuanRT/YouTube.js/commit/9c503f4fa8a750558cedbeca974faf36e304147e))

## [8.0.0](https://github.com/LuanRT/YouTube.js/compare/v7.0.0...v8.0.0) (2023-12-01)


### ⚠ BREAKING CHANGES

* **Library:** Add support for the new layout and remove profile & stats info
* **Channel:** YouTube removed the "Channels" tab on channels, so this pull request removes the `getChannels()` method and `has_channels` getter from the `YT.Channel` class, as they are no longer useful. The featured channels are now shown on the channel home tab. To get them you can use the `channels` getter on the home tab of the channel. Please note that some channel owners might not have added that section to their home page yet, so you won't be able to get the featured channels for those channels. The home tab is the default tab that is returned when you call `InnerTube#getChannel()`, you can also access that tab by calling `getHome()` on a `YT.Channel` object.

### Features

* add `FeedNudge` ([#533](https://github.com/LuanRT/YouTube.js/issues/533)) ([e021395](https://github.com/LuanRT/YouTube.js/commit/e02139532b2c07aaf72dd1bd8610f63b6780001d))
* add `VideoAttributeView` ([#531](https://github.com/LuanRT/YouTube.js/issues/531)) ([ff4ab16](https://github.com/LuanRT/YouTube.js/commit/ff4ab1680e110fc32e09d09215fd3e05dbde2c85))
* Add Shorts endpoint ([#512](https://github.com/LuanRT/YouTube.js/issues/512)) ([a32aa8c](https://github.com/LuanRT/YouTube.js/commit/a32aa8c633b6f3c3bb0695ad1878cbb313867346))
* **Channel:** Support new about popup ([#537](https://github.com/LuanRT/YouTube.js/issues/537)) ([c66eb1f](https://github.com/LuanRT/YouTube.js/commit/c66eb1fecf0e66d9eca841be0ca56b39ad4466eb))
* **parser:** Add `ChannelOwnerEmptyState` ([#541](https://github.com/LuanRT/YouTube.js/issues/541)) ([b60930a](https://github.com/LuanRT/YouTube.js/commit/b60930a0c1ce419dddb753846c84d4e46ddf04e1))
* **Parser:** Add `ClipSection` ([#532](https://github.com/LuanRT/YouTube.js/issues/532)) ([9007b65](https://github.com/LuanRT/YouTube.js/commit/9007b652375e1ca3c3844bdf091fe3670f98dc2c))
* **toDash:** Add `contentType` to audio and video adaption sets ([#539](https://github.com/LuanRT/YouTube.js/issues/539)) ([4806fc6](https://github.com/LuanRT/YouTube.js/commit/4806fc6c112cb3cf0584f7d253f3c4aeaffa9927))
* Use `overrides` instead of `--legacy-peer-deps` ([#529](https://github.com/LuanRT/YouTube.js/issues/529)) ([db7f620](https://github.com/LuanRT/YouTube.js/commit/db7f6209b2329bf18b8b35aababfdb9b750c3b0f))


### Bug Fixes

* **Channel:** Remove `getChannels()` and `has_channels`, as YouTube removed the tab ([#542](https://github.com/LuanRT/YouTube.js/issues/542)) ([6a5a579](https://github.com/LuanRT/YouTube.js/commit/6a5a579e3947109af0e7c2a318aef40edb8484f8))
* **Library:** Add support for the new layout and remove profile & stats info ([4261915](https://github.com/LuanRT/YouTube.js/commit/4261915fd4aa84f7619a45d678910be0ae30e13e))
* **StructuredDescriptionContent:** Add `ReelShelf` to list of possible nodes ([f74ed5a](https://github.com/LuanRT/YouTube.js/commit/f74ed5a1cf352a7b57fa84b9373f9ed9ba1911fc))
* **VideoAttributeView:** Fix `image` and `overflow_menu_on_tap` props ([5ae15be](https://github.com/LuanRT/YouTube.js/commit/5ae15be63dee2a2393a1aa2a308ca5378140760a))


### Performance Improvements

* Use named Parser import, to allow bundlers to create direct function references ([#535](https://github.com/LuanRT/YouTube.js/issues/535)) ([95ed602](https://github.com/LuanRT/YouTube.js/commit/95ed60207a1219f4891f28d2b2b90cf816f11831))

## [7.0.0](https://github.com/LuanRT/YouTube.js/compare/v6.4.1...v7.0.0) (2023-10-28)


### ⚠ BREAKING CHANGES

* **music#getSearchSuggestions:** Return array of `SearchSuggestionsSection` instead of a single node

### Features

* **Kids:** Add `blockChannel` command to easily block channels ([#503](https://github.com/LuanRT/YouTube.js/issues/503)) ([9ab528e](https://github.com/LuanRT/YouTube.js/commit/9ab528ec823dcd527a97150009eed632c6d3eb6a))
* **music#getSearchSuggestions:** Return array of `SearchSuggestionsSection` instead of a single node ([beaa28f](https://github.com/LuanRT/YouTube.js/commit/beaa28f4c68de8366caa84ce5a026bf9e12e1b9d))
* **parser:** Add `PlayerOverflow` and `PlayerControlsOverlay` ([a45273f](https://github.com/LuanRT/YouTube.js/commit/a45273fec498df87eecd364ffb708c9f787793d5))
* **UpdateViewerShipAction:** Add `original_view_count` and `unlabeled_view_count_value` ([#527](https://github.com/LuanRT/YouTube.js/issues/527)) ([bc97e07](https://github.com/LuanRT/YouTube.js/commit/bc97e07ac6d1cdc45194e214c6001cf92190e1d5))


### Bug Fixes

* **build:** Inline package.json import to avoid runtime erros ([#509](https://github.com/LuanRT/YouTube.js/issues/509)) ([4c0de19](https://github.com/LuanRT/YouTube.js/commit/4c0de199e85dd5cc8b3719920b24dec9613acaab))

## [6.4.1](https://github.com/LuanRT/YouTube.js/compare/v6.4.0...v6.4.1) (2023-10-02)


### Bug Fixes

* **Feed:** Do not throw when multiple continuations are present ([8e372d5](https://github.com/LuanRT/YouTube.js/commit/8e372d5c67f148be288bb0485f2c70ec43fbecd0))
* **Playlist:** Throw a more helpful error when parsing empty responses ([987f506](https://github.com/LuanRT/YouTube.js/commit/987f50604a0163f9a07091ce787995c6f6fddb75))


### Performance Improvements

* Cache deciphered n-params by info response ([#505](https://github.com/LuanRT/YouTube.js/issues/505)) ([d2959b3](https://github.com/LuanRT/YouTube.js/commit/d2959b3a55a5081295da4754627913933bbaf1e7))
* **generator:** Remove duplicate checks in `isMiscType` ([#506](https://github.com/LuanRT/YouTube.js/issues/506)) ([68df321](https://github.com/LuanRT/YouTube.js/commit/68df3218580db10c9a0932c93ff2ce487526ff1e))

## [6.4.0](https://github.com/LuanRT/YouTube.js/compare/v6.3.0...v6.4.0) (2023-09-10)


### Features

* Add support for retrieving transcripts ([#500](https://github.com/LuanRT/YouTube.js/issues/500)) ([f94ea6c](https://github.com/LuanRT/YouTube.js/commit/f94ea6cf917f63f30dd66514b22a4cf43b948f07))
* **PlaylistManager:** add .setName() and .setDescription() functions for editing playlists ([#498](https://github.com/LuanRT/YouTube.js/issues/498)) ([86fb33e](https://github.com/LuanRT/YouTube.js/commit/86fb33ed03a127d9fd4caa695ca97642bffe61bd))


### Bug Fixes

* **BackstagePost:** `vote_button` type mismatch ([fba3fc9](https://github.com/LuanRT/YouTube.js/commit/fba3fc971454d66d80d4920fbd60889a221de381))

## [6.3.0](https://github.com/LuanRT/YouTube.js/compare/v6.2.0...v6.3.0) (2023-08-31)


### Features

* **ChannelMetadata:** Add `music_artist_name` ([#497](https://github.com/LuanRT/YouTube.js/issues/497)) ([91de6e5](https://github.com/LuanRT/YouTube.js/commit/91de6e5c0e5b27e6d12ce5db2f500c5ff78b9830))
* **Session:** Add on_behalf_of_user session option. ([#494](https://github.com/LuanRT/YouTube.js/issues/494)) ([8bc2aaa](https://github.com/LuanRT/YouTube.js/commit/8bc2aaa3587fcf79f69eedbc2bf422a4c6fa7eb1))


### Bug Fixes

* **CompactMovie:** Add missing import and remove unnecessary console.log ([#496](https://github.com/LuanRT/YouTube.js/issues/496)) ([c26972c](https://github.com/LuanRT/YouTube.js/commit/c26972c42a6368822ac254c00f1bbee5a1542486))

## [6.2.0](https://github.com/LuanRT/YouTube.js/compare/v6.1.0...v6.2.0) (2023-08-29)


### Features

* **Session:** Add fallback for session data retrieval ([#490](https://github.com/LuanRT/YouTube.js/issues/490)) ([10c15bf](https://github.com/LuanRT/YouTube.js/commit/10c15bfb9f131a2acea2f26ff3328993d8d8f4aa))


### Bug Fixes

* **Format:** Fix `is_original` always being `true` ([#492](https://github.com/LuanRT/YouTube.js/issues/492)) ([0412fa0](https://github.com/LuanRT/YouTube.js/commit/0412fa05ff1f00960b398c2f18d5ce39ce0cb864))

## [6.1.0](https://github.com/LuanRT/YouTube.js/compare/v6.0.2...v6.1.0) (2023-08-27)


### Features

* **parser:** Add `AlertWithButton` ([#486](https://github.com/LuanRT/YouTube.js/issues/486)) ([8b69587](https://github.com/LuanRT/YouTube.js/commit/8b6958778721ba274283f641779fb60bc6f42cd2))
* **parser:** Add `ChannelHeaderLinksView` ([#484](https://github.com/LuanRT/YouTube.js/issues/484)) ([ed7be2a](https://github.com/LuanRT/YouTube.js/commit/ed7be2a675cf1ec663e743e90db6260c97546739))
* **parser:** Add `CompactMovie` ([#487](https://github.com/LuanRT/YouTube.js/issues/487)) ([2eed172](https://github.com/LuanRT/YouTube.js/commit/2eed1726d5bde7648af09273cc14ab4a315cb23e))

## [6.0.2](https://github.com/LuanRT/YouTube.js/compare/v6.0.1...v6.0.2) (2023-08-24)


### Bug Fixes

* invalid set ids in dash manifest ([#480](https://github.com/LuanRT/YouTube.js/issues/480)) ([1c3ea2a](https://github.com/LuanRT/YouTube.js/commit/1c3ea2acd38652c6b40a0817a7836c672a776c4e))

## [6.0.1](https://github.com/LuanRT/YouTube.js/compare/v6.0.0...v6.0.1) (2023-08-22)


### Bug Fixes

* **SearchSubMenu:** Groups not being parsed due to a typo ([90be877](https://github.com/LuanRT/YouTube.js/commit/90be877d28e0ef013056eaeaa4f2765c91addd61))

## [6.0.0](https://github.com/LuanRT/YouTube.js/compare/v5.8.0...v6.0.0) (2023-08-18)


### ⚠ BREAKING CHANGES

* replace unnecessary classes with pure functions ([#468](https://github.com/LuanRT/YouTube.js/issues/468))

### Features

* **MusicResponsiveListItem:** Detect non music tracks properly ([815e54b](https://github.com/LuanRT/YouTube.js/commit/815e54b854fcda3f5423231c8495ce1fb69d8237))
* **parser:** add `MusicMultiRowListItem` ([494ee87](https://github.com/LuanRT/YouTube.js/commit/494ee8776af0839d3ee2cca3d2fd836680cfdb9e))
* **Session:** Add `IOS` to `ClientType` enum ([22a38c0](https://github.com/LuanRT/YouTube.js/commit/22a38c0762499de74f0aeb3ef01332f893518b08))
* **VideoInfo:** support iOS client ([#467](https://github.com/LuanRT/YouTube.js/issues/467)) ([46fe18b](https://github.com/LuanRT/YouTube.js/commit/46fe18b763e0c943b24ea10fdf25456ab9ade709))


### Bug Fixes

* **Format:** Extracting audio language from captions ([#470](https://github.com/LuanRT/YouTube.js/issues/470)) ([31d27b1](https://github.com/LuanRT/YouTube.js/commit/31d27b1bca489ee0053d2783f1a956609845a901))
* **parser:** Allow any property in the `RawResponse` interface ([3bc53a8](https://github.com/LuanRT/YouTube.js/commit/3bc53a8c12e65b22f19a3e337641196b692a94db))
* **parser:** Logger logging `classdata` as `[Object object]` ([bf1510b](https://github.com/LuanRT/YouTube.js/commit/bf1510b235e3ee7d13d51f092babd1105c3d6b9f))
* **Playlist:** Only try extracting the subtitle for the first page ([#465](https://github.com/LuanRT/YouTube.js/issues/465)) ([e370116](https://github.com/LuanRT/YouTube.js/commit/e3701160928e9e959b88ca215c6b0a44c70ca6e6))
* **toDash:** Format grouping into AdaptationSets ([#462](https://github.com/LuanRT/YouTube.js/issues/462)) ([1ff3e1a](https://github.com/LuanRT/YouTube.js/commit/1ff3e1a440389e71055d4b201c29021ca5b39254))


### Performance Improvements

* Cleanup some unnecessary uses of `YTNode#key` and `Maybe` ([#463](https://github.com/LuanRT/YouTube.js/issues/463)) ([0dda97e](https://github.com/LuanRT/YouTube.js/commit/0dda97e0b03171de52d7f11a5abf78911e74cead))


### Code Refactoring

* replace unnecessary classes with pure functions ([#468](https://github.com/LuanRT/YouTube.js/issues/468)) ([87ed396](https://github.com/LuanRT/YouTube.js/commit/87ed3960ffa1c738b6f3b5acaf423647db4d367e))

## [5.8.0](https://github.com/LuanRT/YouTube.js/compare/v5.7.1...v5.8.0) (2023-07-30)


### Features

* **YouTube Playlist:** Add subtitle and fix author optionality ([#458](https://github.com/LuanRT/YouTube.js/issues/458)) ([0fa5a85](https://github.com/LuanRT/YouTube.js/commit/0fa5a859ae15a35266297079e3e34fd9f3a5ebf4))

## [5.7.1](https://github.com/LuanRT/YouTube.js/compare/v5.7.0...v5.7.1) (2023-07-25)


### Bug Fixes

* **SearchHeader:** remove console.log ([d91695a](https://github.com/LuanRT/YouTube.js/commit/d91695a9ec6c55445cbeedba4ace4ac1e0a72eee))

## [5.7.0](https://github.com/LuanRT/YouTube.js/compare/v5.6.0...v5.7.0) (2023-07-24)


### Features

* **parser:** Add `PageHeader` ([#450](https://github.com/LuanRT/YouTube.js/issues/450)) ([18cbc8c](https://github.com/LuanRT/YouTube.js/commit/18cbc8c038ddddffa1ba1519e56a8054b2996e42))
* **parser:** Add `SearchHeader` ([6997982](https://github.com/LuanRT/YouTube.js/commit/6997982cf2db87edf4929e9a77e2690e7b630d3d)), closes [#452](https://github.com/LuanRT/YouTube.js/issues/452)

## [5.6.0](https://github.com/LuanRT/YouTube.js/compare/v5.5.0...v5.6.0) (2023-07-18)


### Features

* **parser:** Add `IncludingResultsFor` ([#447](https://github.com/LuanRT/YouTube.js/issues/447)) ([c477b82](https://github.com/LuanRT/YouTube.js/commit/c477b824c084552169062f72cde8890e77b31f59))
* **toDash:** Add option to include thumbnails in the manifest ([#446](https://github.com/LuanRT/YouTube.js/issues/446)) ([1a03473](https://github.com/LuanRT/YouTube.js/commit/1a034733f6bb641e2d97df12de81ae3516c1f703))

## [5.5.0](https://github.com/LuanRT/YouTube.js/compare/v5.4.0...v5.5.0) (2023-07-16)


### Features

* **Format:** Populate audio language from captions when available ([#445](https://github.com/LuanRT/YouTube.js/issues/445)) ([bdd98a3](https://github.com/LuanRT/YouTube.js/commit/bdd98a3b9be39c11942043a300a6ebce9a15efc6))
* **parser:** Add `CommentsSimplebox` parser ([#442](https://github.com/LuanRT/YouTube.js/issues/442)) ([555d257](https://github.com/LuanRT/YouTube.js/commit/555d257459b76d7c0158e9c6b189a75a82b10faf))
* **parser:** Add `HashtagTile` ([#440](https://github.com/LuanRT/YouTube.js/issues/440)) ([ae2557d](https://github.com/LuanRT/YouTube.js/commit/ae2557d15c9df09bb92e0dc6191670d72b36631a))
* **parser:** add `MacroMarkersList` ([#444](https://github.com/LuanRT/YouTube.js/issues/444)) ([708c5f7](https://github.com/LuanRT/YouTube.js/commit/708c5f7394b4ea140836b9483848cb61b97ea1af))
* **parser:** Add `ShowMiniplayerCommand` ([#443](https://github.com/LuanRT/YouTube.js/issues/443)) ([a9cdbf7](https://github.com/LuanRT/YouTube.js/commit/a9cdbf7010e7b9b9cfde5db645d51bdad51006c5))


### Bug Fixes

* **package:** Bump Jinter to fix bad export order ([#439](https://github.com/LuanRT/YouTube.js/issues/439)) ([2aef678](https://github.com/LuanRT/YouTube.js/commit/2aef67876ec19118b37d3cecd429ccf8239989e0))
* **StructuredDescriptionContent:** `items` can also be a `HorizontalCardList` ([b50d1ef](https://github.com/LuanRT/YouTube.js/commit/b50d1ef67d81276864818de10c61b5a7980cbc1a))

## [5.4.0](https://github.com/LuanRT/YouTube.js/compare/v5.3.0...v5.4.0) (2023-07-14)


### Features

* **Channel:** Add `getPodcasts()` method ([f267fcd](https://github.com/LuanRT/YouTube.js/commit/f267fcd8beccf237b8d1924463990273887cae28))
* **Channel:** Add `getReleases()` method ([f267fcd](https://github.com/LuanRT/YouTube.js/commit/f267fcd8beccf237b8d1924463990273887cae28))
* **parser:** Add `Quiz` ([#437](https://github.com/LuanRT/YouTube.js/issues/437)) ([cffa868](https://github.com/LuanRT/YouTube.js/commit/cffa868c6eeb579047653fac65da8e913fb3c621))


### Bug Fixes

* **Playlist:** Parse `PlaylistCustomThumbnail` for `thumbnail_renderer` ([f267fcd](https://github.com/LuanRT/YouTube.js/commit/f267fcd8beccf237b8d1924463990273887cae28))

## [5.3.0](https://github.com/LuanRT/YouTube.js/compare/v5.2.1...v5.3.0) (2023-07-11)


### Features

* **toDash:** Add color information ([#430](https://github.com/LuanRT/YouTube.js/issues/430)) ([3500e92](https://github.com/LuanRT/YouTube.js/commit/3500e926327d560b1db036bfe503c276b91922ac))


### Performance Improvements

* **Format:** Cleanup the xtags parsing ([#434](https://github.com/LuanRT/YouTube.js/issues/434)) ([1ca2083](https://github.com/LuanRT/YouTube.js/commit/1ca20836bf343c78461fab7ad3b71db2b96e65c3))
* **toDash:** Hoist duplicates from Representation to AdaptationSet ([#431](https://github.com/LuanRT/YouTube.js/issues/431)) ([5f058e6](https://github.com/LuanRT/YouTube.js/commit/5f058e69ae8594491133f7f96287bea4137f7822))

## [5.2.1](https://github.com/LuanRT/YouTube.js/compare/v5.2.0...v5.2.1) (2023-07-04)


### Bug Fixes

* incorrect node parser implementations ([#428](https://github.com/LuanRT/YouTube.js/issues/428)) ([222dfce](https://github.com/LuanRT/YouTube.js/commit/222dfce6bbd13b2cd80ae11540cbc0edd9053fc5))

## [5.2.0](https://github.com/LuanRT/YouTube.js/compare/v5.1.0...v5.2.0) (2023-06-28)


### Features

* **VideoDetails:** Add is_post_live_dvr property ([#411](https://github.com/LuanRT/YouTube.js/issues/411)) ([a11e596](https://github.com/LuanRT/YouTube.js/commit/a11e5962c6eb73b14623a9de1e6c8c2534146b1e))
* **ytmusic:** Add support for YouTube Music mood filters ([#404](https://github.com/LuanRT/YouTube.js/issues/404)) ([77b39c7](https://github.com/LuanRT/YouTube.js/commit/77b39c79ee0768eb203b7d47ea81286d470c21f2))


### Bug Fixes

* **OAuth:** client identity matching ([#421](https://github.com/LuanRT/YouTube.js/issues/421)) ([07c1b3e](https://github.com/LuanRT/YouTube.js/commit/07c1b3e0e57cb1fa42e4772775bfd1437bbc731f))
* **PlayerEndpoint:** Use different player params ([#419](https://github.com/LuanRT/YouTube.js/issues/419)) ([519be72](https://github.com/LuanRT/YouTube.js/commit/519be72445b7ff392b396e16bcb1dc05c7df8976))
* **Playlist:** Add thumbnail_renderer on Playlist when response includes it ([#424](https://github.com/LuanRT/YouTube.js/issues/424)) ([4f9427d](https://github.com/LuanRT/YouTube.js/commit/4f9427d752e89faec8dd1c4fd7a9607dca998c7a))
* **VideoInfo.ts:** reimplement `get music_tracks` ([#409](https://github.com/LuanRT/YouTube.js/issues/409)) ([e434bb2](https://github.com/LuanRT/YouTube.js/commit/e434bb2632fe2b20aab6f1e707a93ca76f9d5c91))


### Performance Improvements

* **Search:** Speed up results parsing ([#408](https://github.com/LuanRT/YouTube.js/issues/408)) ([1e07a18](https://github.com/LuanRT/YouTube.js/commit/1e07a184ffaff508ad5ba869cb5e7dc9f095f744))
* **toDash:** Speed up format filtering ([#405](https://github.com/LuanRT/YouTube.js/issues/405)) ([5de7b24](https://github.com/LuanRT/YouTube.js/commit/5de7b24dc55fca3eb8fccc6fa30d3c2cd60b8184))

## [5.1.0](https://github.com/LuanRT/YouTube.js/compare/v5.0.4...v5.1.0) (2023-05-14)


### Features

* **ReelItem:** Add accessibility label ([#401](https://github.com/LuanRT/YouTube.js/issues/401)) ([046103a](https://github.com/LuanRT/YouTube.js/commit/046103a4d8af09fafefab6e9f971184eeca75c2e))
* **toDash:** Add audio track labels to the manifest when available ([#402](https://github.com/LuanRT/YouTube.js/issues/402)) ([84b4f1e](https://github.com/LuanRT/YouTube.js/commit/84b4f1efd111321e4f3e5a87844790c4ec9b0b52))

## [5.0.4](https://github.com/LuanRT/YouTube.js/compare/v5.0.3...v5.0.4) (2023-05-10)


### Bug Fixes

* **bundles:** Use ESM tslib build for the browser bundles ([#397](https://github.com/LuanRT/YouTube.js/issues/397)) ([2673419](https://github.com/LuanRT/YouTube.js/commit/26734194ab0bc5a9f57e1c509d7646ce8903d0c6))
* **Utils:** Circular dependency introduced in 38a83c3c2aa814150d1d9b8ed99fca915c1d67fe ([#400](https://github.com/LuanRT/YouTube.js/issues/400)) ([66b026b](https://github.com/LuanRT/YouTube.js/commit/66b026bf493d71a39e12825938fe54dc63aefd16))
* **Utils:** Use instanceof in deepCompare instead of the constructor name ([#398](https://github.com/LuanRT/YouTube.js/issues/398)) ([38a83c3](https://github.com/LuanRT/YouTube.js/commit/38a83c3c2aa814150d1d9b8ed99fca915c1d67fe))

## [5.0.3](https://github.com/LuanRT/YouTube.js/compare/v5.0.2...v5.0.3) (2023-05-03)


### Bug Fixes

* **Video:** typo causing node parsing to fail ([3b0498b](https://github.com/LuanRT/YouTube.js/commit/3b0498b68b5378e63283e792bd45571c0b919e0b))

## [5.0.2](https://github.com/LuanRT/YouTube.js/compare/v5.0.1...v5.0.2) (2023-04-30)


### Bug Fixes

* **VideoInfo:** Use microformat view_count when videoDetails view_count is NaN ([#393](https://github.com/LuanRT/YouTube.js/issues/393)) ([7c0abfc](https://github.com/LuanRT/YouTube.js/commit/7c0abfccd78a6c291d898f898d73a4f16170e2a9))

## [5.0.1](https://github.com/LuanRT/YouTube.js/compare/v5.0.0...v5.0.1) (2023-04-30)


### Bug Fixes

* **web:** slow downloads due to visitor data ([#391](https://github.com/LuanRT/YouTube.js/issues/391)) ([4f7ec07](https://github.com/LuanRT/YouTube.js/commit/4f7ec07c3f689219b07e8291877c23b6fbf45fb1))

## [5.0.0](https://github.com/LuanRT/YouTube.js/compare/v4.3.0...v5.0.0) (2023-04-29)


### ⚠ BREAKING CHANGES

* overhaul core classes and remove redundant code ([#388](https://github.com/LuanRT/YouTube.js/issues/388))

### Features

* **NavigationEndpoint:** parse `content` prop ([dd21f8c](https://github.com/LuanRT/YouTube.js/commit/dd21f8c75ae1d76180faab4f0ef9ee40920966e3))


### Bug Fixes

* **android:** workaround streaming URLs returning 403 ([#390](https://github.com/LuanRT/YouTube.js/issues/390)) ([75ea09d](https://github.com/LuanRT/YouTube.js/commit/75ea09dde86b1bdf13b197d6e02701899300a371))


### Code Refactoring

* overhaul core classes and remove redundant code ([#388](https://github.com/LuanRT/YouTube.js/issues/388)) ([95e0294](https://github.com/LuanRT/YouTube.js/commit/95e0294eabfdb20bbee2a4bfb751fd101402c5d6))

## [4.3.0](https://github.com/LuanRT/YouTube.js/compare/v4.2.0...v4.3.0) (2023-04-13)


### Features

* **GridVideo:** add `upcoming`, `upcoming_text`, `is_reminder_set` and `buttons` ([05de3ec](https://github.com/LuanRT/YouTube.js/commit/05de3ec97a1fea92543b5e5f84933b86a07ab830)), closes [#380](https://github.com/LuanRT/YouTube.js/issues/380)
* **MusicResponsiveListItem:** make flex/fixed cols public ([#382](https://github.com/LuanRT/YouTube.js/issues/382)) ([096bf36](https://github.com/LuanRT/YouTube.js/commit/096bf362c9bd46a510ecb0d01623c70841e26e26))
* **ToggleMenuServiceItem:** parse default nav endpoint ([a056696](https://github.com/LuanRT/YouTube.js/commit/a0566969ba436f31ca3722d09442a0c6302235d7))
* **ytmusic:** add taste builder nodes ([#383](https://github.com/LuanRT/YouTube.js/issues/383)) ([a9cad49](https://github.com/LuanRT/YouTube.js/commit/a9cad49333aa85c98bbb96e5f2d5b57d9beeb0c7))

## [4.2.0](https://github.com/LuanRT/YouTube.js/compare/v4.1.1...v4.2.0) (2023-04-09)


### Features

* Enable importHelpers in tsconfig to reduce output size ([#378](https://github.com/LuanRT/YouTube.js/issues/378)) ([0b301de](https://github.com/LuanRT/YouTube.js/commit/0b301de6a1e1352a64881c1751a84360922a77cd))
* **parser:** ignore PrimetimePromo node ([ce9d9c5](https://github.com/LuanRT/YouTube.js/commit/ce9d9c56b4f45c0139d74edc95c295ecfd1ee4b1))
* **PlaylistVideo:** Extract video_info and accessibility_label texts ([#376](https://github.com/LuanRT/YouTube.js/issues/376)) ([c9135e6](https://github.com/LuanRT/YouTube.js/commit/c9135e66d3c9c72b8d063eedcf3cc2123800946d))

## [4.1.1](https://github.com/LuanRT/YouTube.js/compare/v4.1.0...v4.1.1) (2023-03-29)


### Bug Fixes

* **PlayerCaptionsTracklist:** parse props only if they exist in the node ([470d8d9](https://github.com/LuanRT/YouTube.js/commit/470d8d94063f0159cd005c9eb15fd1a4a175bea0)), closes [#372](https://github.com/LuanRT/YouTube.js/issues/372)
* **Search:** Return search results even if there are ads ([#373](https://github.com/LuanRT/YouTube.js/issues/373)) ([2c5907f](https://github.com/LuanRT/YouTube.js/commit/2c5907f80fd76452afe87d1722fe35a4f45a22e0))

## [4.1.0](https://github.com/LuanRT/YouTube.js/compare/v4.0.1...v4.1.0) (2023-03-24)


### Features

* **Session:** allow setting a custom visitor data token ([#371](https://github.com/LuanRT/YouTube.js/issues/371)) ([13ebf0a](https://github.com/LuanRT/YouTube.js/commit/13ebf0a03973e7ba7b65e9f72c4333927e4254f6))
* **ShowingResultsFor:** parse all props ([1d9587e](https://github.com/LuanRT/YouTube.js/commit/1d9587e8c1ee0b11bb0e444c3d1e98162e9e1059))


### Bug Fixes

* **http:** android tv http client missing `clientName` ([#370](https://github.com/LuanRT/YouTube.js/issues/370)) ([cb8fafe](https://github.com/LuanRT/YouTube.js/commit/cb8fafe94b8ab330ae58211a892923321d65d890))
* **node:** Electron apps crashing ([#367](https://github.com/LuanRT/YouTube.js/issues/367)) ([e7eacd9](https://github.com/LuanRT/YouTube.js/commit/e7eacd974211c90e7fbddfbf8019388cda3dfa5a))
* **parser:** Make Video.is_live work on channel pages ([#368](https://github.com/LuanRT/YouTube.js/issues/368)) ([bd35faa](https://github.com/LuanRT/YouTube.js/commit/bd35faa5978f0b822e98d019523be1303374ddc0))
* **toDash:** Generate unique Representation ids ([#366](https://github.com/LuanRT/YouTube.js/issues/366)) ([a8b507e](https://github.com/LuanRT/YouTube.js/commit/a8b507ee65ccd8edc9aea2aef8a908fa272bb23c))
* **Utils:** Properly parse timestamps with thousands separators ([#363](https://github.com/LuanRT/YouTube.js/issues/363)) ([1c72a41](https://github.com/LuanRT/YouTube.js/commit/1c72a41675e47f711bd61ebb898bca5527406a79))

## [4.0.1](https://github.com/LuanRT/YouTube.js/compare/v4.0.0...v4.0.1) (2023-03-16)


### Bug Fixes

* **Channel:** type mismatch in `subscribe_button` prop ([573c864](https://github.com/LuanRT/YouTube.js/commit/573c8643aae16ec7b6be5b333619a5d8c91ca5c1))

## [4.0.0](https://github.com/LuanRT/YouTube.js/compare/v3.3.0...v4.0.0) (2023-03-15)


### ⚠ BREAKING CHANGES

* **Parser:** general refactoring of parsers ([#344](https://github.com/LuanRT/YouTube.js/issues/344))
* The `toDash` functions are now asynchronous, they now return a `Promise<string>` instead of a `string`, as we need to fetch the first sequence of the OTF format streams while building the manifest.

### Features

* Add support for OTF format streams ([3e4d41b](https://github.com/LuanRT/YouTube.js/commit/3e4d41bf06ba16232979977c705444f2032bcde6))
* **parser:** add `GridMix` ([#356](https://github.com/LuanRT/YouTube.js/issues/356)) ([a8e7e64](https://github.com/LuanRT/YouTube.js/commit/a8e7e644ec6df3b3c98a313f0321da27b4ca456e))
* **parser:** add `GridShow` and `ShowCustomThumbnail` ([8ef4b42](https://github.com/LuanRT/YouTube.js/commit/8ef4b42d444c4fbe5cd65a55c0e0e7aa31738755)), closes [#459](https://github.com/LuanRT/YouTube.js/issues/459)
* **parser:** add `MusicCardShelf` ([#358](https://github.com/LuanRT/YouTube.js/issues/358)) ([9b005d6](https://github.com/LuanRT/YouTube.js/commit/9b005d62d6590a2ddf6848dabfa33fce36e8df9c))
* **parser:** Add `play_all_button` to `Shelf` ([#345](https://github.com/LuanRT/YouTube.js/issues/345)) ([427db5b](https://github.com/LuanRT/YouTube.js/commit/427db5bbc2bf3e8ec60371d504c2ab1cdae6e918))
* **parser:** add `view_playlist` to `Playlist` ([#348](https://github.com/LuanRT/YouTube.js/issues/348)) ([9cb4530](https://github.com/LuanRT/YouTube.js/commit/9cb45302997771d909487b1ecba6f38655abef48))
* **parser:** add InfoPanelContent and InfoPanelContainer nodes ([4784dfa](https://github.com/LuanRT/YouTube.js/commit/4784dfa563a4dbeaee31811824d5aa37a67f5557)), closes [#326](https://github.com/LuanRT/YouTube.js/issues/326)
* **Parser:** just-in-time YTNode generation ([#310](https://github.com/LuanRT/YouTube.js/issues/310)) ([2cee590](https://github.com/LuanRT/YouTube.js/commit/2cee59024c730c34aa06052849ed6fb3f862ef33))
* **yt:** add support for movie items and trailers ([#349](https://github.com/LuanRT/YouTube.js/issues/349)) ([9f1c31d](https://github.com/LuanRT/YouTube.js/commit/9f1c31d7a09532e80a187b14acceff31c22579bf))


### Code Refactoring

* **Parser:** general refactoring of parsers ([#344](https://github.com/LuanRT/YouTube.js/issues/344)) ([b13bf6e](https://github.com/LuanRT/YouTube.js/commit/b13bf6e9926c19a1939e0f4b69cbd53d1af0f7c8))

## [3.3.0](https://github.com/LuanRT/YouTube.js/compare/v3.2.0...v3.3.0) (2023-03-09)


### Features

* **parser:** add `ConversationBar` node ([b2253df](https://github.com/LuanRT/YouTube.js/commit/b2253df8022217dc486155d8cacbf22db04dd9c2))
* **VideoInfo:** support get by endpoint + more info ([#342](https://github.com/LuanRT/YouTube.js/issues/342)) ([0d35fe0](https://github.com/LuanRT/YouTube.js/commit/0d35fe0ca5e87a877b76cbb6cf3c92843eac5a99))


### Bug Fixes

* **MultiMarkersPlayerBar:** avoid observing undefined objects ([f351770](https://github.com/LuanRT/YouTube.js/commit/f3517708ff34093a544c09d6f5f1ec806130d5cc))
* **SharedPost:** import `Menu` node directly (oops) ([3e3dc35](https://github.com/LuanRT/YouTube.js/commit/3e3dc351bb44faec87616d9b922924d14a95f29f))
* **ytmusic:** use static visitor id to avoid empty API responses ([f9754f5](https://github.com/LuanRT/YouTube.js/commit/f9754f5ac61d0f11b025f37f93783f971560268b)), closes [#279](https://github.com/LuanRT/YouTube.js/issues/279)

## [3.2.0](https://github.com/LuanRT/YouTube.js/compare/v3.1.1...v3.2.0) (2023-03-08)


### Features

* Add support for descriptive audio tracks ([#338](https://github.com/LuanRT/YouTube.js/issues/338)) ([574b67a](https://github.com/LuanRT/YouTube.js/commit/574b67a1f707a32378586dd2fe7b2f36f4ab6ddb))
* export `FormatUtils`' types ([2d774e2](https://github.com/LuanRT/YouTube.js/commit/2d774e26aae79f3d1b115e0e85c148ae80985529))
* **parser:** add `banner` to `PlaylistHeader` ([#337](https://github.com/LuanRT/YouTube.js/issues/337)) ([95033e7](https://github.com/LuanRT/YouTube.js/commit/95033e723ef912706e4d176de6b2760f017184e1))
* **parser:** SharedPost ([#332](https://github.com/LuanRT/YouTube.js/issues/332)) ([ce53ac1](https://github.com/LuanRT/YouTube.js/commit/ce53ac18435cbcb20d6d4c4ab52fd156091e7592))
* **VideoInfo:** add `game_info` and `category` ([#333](https://github.com/LuanRT/YouTube.js/issues/333)) ([214aa14](https://github.com/LuanRT/YouTube.js/commit/214aa147ce6306e37a6bf860a7bed5635db4797e))
* **YouTube/Search:** add `SearchSubMenu` node ([#340](https://github.com/LuanRT/YouTube.js/issues/340)) ([a511608](https://github.com/LuanRT/YouTube.js/commit/a511608f18b37b0d9f2c7958ed5128330fabcfa0))
* **yt:** add `getGuide()` ([#335](https://github.com/LuanRT/YouTube.js/issues/335)) ([2cc7b8b](https://github.com/LuanRT/YouTube.js/commit/2cc7b8bcd6938c7fb3af4f854a1d78b86d153873))


### Bug Fixes

* **SegmentedLikeDislikeButton:** like/dislike buttons can also be a simple `Button` ([9b2738f](https://github.com/LuanRT/YouTube.js/commit/9b2738f1285b278c3e83541857651be9a6248288))
* **YouTube:** fix warnings when retrieving members-only content ([#341](https://github.com/LuanRT/YouTube.js/issues/341)) ([95f1d40](https://github.com/LuanRT/YouTube.js/commit/95f1d4077ff3775f36967dca786139a09e2830a2))
* **ytmusic:** export search filters type ([cf8a33c](https://github.com/LuanRT/YouTube.js/commit/cf8a33c79f5432136b865d535fd0ecedc2393382))

## [3.1.1](https://github.com/LuanRT/YouTube.js/compare/v3.1.0...v3.1.1) (2023-03-01)


### Bug Fixes

* **Channel:** getting community continuations ([#329](https://github.com/LuanRT/YouTube.js/issues/329)) ([4c7b8a3](https://github.com/LuanRT/YouTube.js/commit/4c7b8a34030effa26c4ea186d3e9509128aec31c))

## [3.1.0](https://github.com/LuanRT/YouTube.js/compare/v3.0.0...v3.1.0) (2023-02-26)


### Features

* Add upcoming and live info to playlist videos ([#317](https://github.com/LuanRT/YouTube.js/issues/317)) ([a0bfe16](https://github.com/LuanRT/YouTube.js/commit/a0bfe164279ec27b0c49c6b0c32222c1a92df5c3))
* **VideoSecondaryInfo:** add support for attributed descriptions ([#325](https://github.com/LuanRT/YouTube.js/issues/325)) ([f933cb4](https://github.com/LuanRT/YouTube.js/commit/f933cb45bcb92c07b3bc063d63869a51cbff4eb0))


### Bug Fixes

* **parser:** export YTNodes individually so they can be used as types ([200632f](https://github.com/LuanRT/YouTube.js/commit/200632f374d5e0e105b600d579a2665a6fb36e38)), closes [#321](https://github.com/LuanRT/YouTube.js/issues/321)
* **PlayerMicroformat:** Make the embed field optional ([#320](https://github.com/LuanRT/YouTube.js/issues/320)) ([a0e6cef](https://github.com/LuanRT/YouTube.js/commit/a0e6cef00fb9e3f52593cec22704f7ddc1f7553e))
* send correct UA for Android requests ([f4e0f30](https://github.com/LuanRT/YouTube.js/commit/f4e0f30e6e94b347b28d67d9a86284ea2d23ee15)), closes [#322](https://github.com/LuanRT/YouTube.js/issues/322)

## [3.0.0](https://github.com/LuanRT/YouTube.js/compare/v2.9.0...v3.0.0) (2023-02-17)


### ⚠ BREAKING CHANGES

* cleanup platform support ([#306](https://github.com/LuanRT/YouTube.js/issues/306))

### Features

* add parser support for MultiImage community posts ([#298](https://github.com/LuanRT/YouTube.js/issues/298)) ([de61782](https://github.com/LuanRT/YouTube.js/commit/de61782f1a673cbe66ae9b410341e39b7501ba84))
* add support for hashtag feeds ([#312](https://github.com/LuanRT/YouTube.js/issues/312)) ([bf12740](https://github.com/LuanRT/YouTube.js/commit/bf12740333a82c26fe84e7c702c2fbb8859814fc))
* add support for YouTube Kids ([#291](https://github.com/LuanRT/YouTube.js/issues/291)) ([2bbefef](https://github.com/LuanRT/YouTube.js/commit/2bbefefbb7cb061f3e7b686158b7568c32f0da5d))
* allow checking whether a channel has optional tabs ([#296](https://github.com/LuanRT/YouTube.js/issues/296)) ([ceefbed](https://github.com/LuanRT/YouTube.js/commit/ceefbed98c70bb936e2d2df58c02834842acfdfc))
* **Channel:** Add getters for all optional tabs ([#303](https://github.com/LuanRT/YouTube.js/issues/303)) ([b2900f4](https://github.com/LuanRT/YouTube.js/commit/b2900f48a7aa4c22635e1819ba9f636e81964f2c))
* **Channel:** add support for sorting the playlist tab ([#295](https://github.com/LuanRT/YouTube.js/issues/295)) ([50ef712](https://github.com/LuanRT/YouTube.js/commit/50ef71284db41e5f94bb511892651d22a1d363a0))
* extract channel error alert ([0b99180](https://github.com/LuanRT/YouTube.js/commit/0b991800a5c67f0e702251982b52eb8531f36f19))
* **FormatUtils:** support multiple audio tracks in the DASH manifest ([#308](https://github.com/LuanRT/YouTube.js/issues/308)) ([a69e43b](https://github.com/LuanRT/YouTube.js/commit/a69e43bf3ae02f2428c4aa86f647e3e5e0db5ba6))
* improve support for dubbed content ([#293](https://github.com/LuanRT/YouTube.js/issues/293)) ([d6c5a9b](https://github.com/LuanRT/YouTube.js/commit/d6c5a9b971444d0cd746aaf5310d3389793680ea))
* parse isLive in CompactVideo ([#294](https://github.com/LuanRT/YouTube.js/issues/294)) ([2acb7da](https://github.com/LuanRT/YouTube.js/commit/2acb7da0198bfeca6ff911cf95cf06a220fccaa5))
* **parser:** add `ChannelAgeGate` node ([1cdf701](https://github.com/LuanRT/YouTube.js/commit/1cdf701c8403db6b681a26ecb1df2daa51add454))
* **parser:** Text#toHTML ([#300](https://github.com/LuanRT/YouTube.js/issues/300)) ([e82e23d](https://github.com/LuanRT/YouTube.js/commit/e82e23dfbb24dff3ddf45754c7319d783990e254))
* **ytkids:** add `getChannel()` ([#292](https://github.com/LuanRT/YouTube.js/issues/292)) ([0fc29f0](https://github.com/LuanRT/YouTube.js/commit/0fc29f0bbf965215146a6ae192494c74e6cefcbb))


### Bug Fixes

* assign MetadataBadge's label ([#311](https://github.com/LuanRT/YouTube.js/issues/311)) ([e37cf62](https://github.com/LuanRT/YouTube.js/commit/e37cf627322f688fcef18d41345f77cbccd58829))
* **ChannelAboutFullMetadata:** fix error when there are no primary links ([#299](https://github.com/LuanRT/YouTube.js/issues/299)) ([f62c66d](https://github.com/LuanRT/YouTube.js/commit/f62c66db396ba7d2f93007414101112b49d8375f))
* **TopicChannelDetails:** avatar and subtitle parsing ([#302](https://github.com/LuanRT/YouTube.js/issues/302)) ([d612590](https://github.com/LuanRT/YouTube.js/commit/d612590530f5fe590fee969810b1dd44c37f0457))
* **VideoInfo:** Gracefully handle missing watch next continuation ([#288](https://github.com/LuanRT/YouTube.js/issues/288)) ([13ad377](https://github.com/LuanRT/YouTube.js/commit/13ad3774c9783ed2a9f286aeee88110bd43b3a73))


### Code Refactoring

* cleanup platform support ([#306](https://github.com/LuanRT/YouTube.js/issues/306)) ([2ccbe2c](https://github.com/LuanRT/YouTube.js/commit/2ccbe2ce6260ace3bfac8b4b391e583fbcc4e286))

LuanRT/YouTube.js/blob/main/COLLABORATORS.md:

# Collaborators

This page lists the collaborators who have contributed to the development and success of the project.

## [LuanRT](https://github.com/LuanRT)
[![Github Sponsors](https://img.shields.io/badge/donate-30363D?style=flat-square&logo=GitHub-Sponsors&logoColor=#white)](https://github.com/sponsors/LuanRT) 

Owner and maintainer.

## [Wykerd](https://github.com/wykerd/)
Initial parser implementation, several bug fixes, major refactorings and general maintenance.

## [MasterOfBob777](https://github.com/MasterOfBob777)
Bug fixes and TypeScript support.

## [patrickkfkan](https://github.com/patrickkfkan)
Major refactorings, improved YouTube Music support, and bug fixes.

## [Absidue](https://github.com/absidue)
Several bug fixes, new features & improved MPD support.

LuanRT/YouTube.js/blob/main/CONTRIBUTING.md:

Welcome to YouTube.js! We're thrilled to have you interested in contributing to our project. As a community-driven project, we believe in the power of collaboration and look forward to working with you. To get started, please follow our guidelines:

## Issues

### Creating a new issue
Before creating a new issue, we recommend searching for similar or related issues to avoid duplication efforts. However, if you can't find one, you're more than welcome to create a new issue using a relevant issue form. Please make sure to describe the issue as clearly and concisely as possible.

### Solving an issue
If you want to lend a hand by solving an issue, it's always good to browse existing issues to find one that grabs your attention. You can narrow down the search using tags as filters. If you find an issue you'd like to help with, please feel free to open a Pull Request with a fix. We appreciate documentation updates and grammar fixes too!

## Making Changes

1. Fork the repository on GitHub.
2. Ensure that you have the latest Node.js v16 version installed.
3. Create a working branch and start making your changes and improvements!

### Committing updates
When you're done with the changes, make sure to commit them. Don't forget to write a clear, descriptive commit message. We recommend following the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification.

### Creating a Pull Request
Once you're happy with your updates, create a pull request on GitHub. This is the most efficient way to get your contribution reviewed and eventually merged into our codebase.

- Use the pull request template to fill in the necessary details.
- If you're solving an issue, link the pull request to that issue.
- Enable the checkbox to allow maintainers to edit the branch and update it for merging.
- Changes may be required before we can merge your changes, and we'll let you know what needs to be done.

### Testing, Linting, and Building
We have some automated processes set up for testing, linting, and building. Please run the following commands to test, lint, and build your code before submitting it:

Testing:
```sh
npm run test

Linting:

npm run lint

Building:

# Build all
npm run build

# Protobuf
npm run build:proto

# Parser map
npm run build:parser-map

# Deno
npm run build:deno

# ES Module
npm run build:esm

# Node
npm run bundle:node

# Browser
npm run bundle:browser
npm run bundle:browser:prod

We appreciate your efforts and contributions to YouTube.js! Together, we can make this project even better.


LuanRT/YouTube.js/blob/main/README.md:

```md
<!-- BADGE LINKS -->
[npm]: https://www.npmjs.com/package/youtubei.js
[versions]: https://www.npmjs.com/package/youtubei.js?activeTab=versions
[codefactor]: https://www.codefactor.io/repository/github/luanrt/youtube.js
[actions]: https://github.com/LuanRT/YouTube.js/actions
[collaborators]: https://github.com/LuanRT/YouTube.js/blob/main/COLLABORATORS.md

<!-- OTHER LINKS -->
[project]: https://github.com/LuanRT/YouTube.js
[twitter]: https://twitter.com/thesciencephile
[discord]: https://discord.gg/syDu7Yks54

<div align="center">
  <br/>
  <p>
    <a href="https://github.com/LuanRT/YouTube.js"><img src="https://luanrt.github.io/assets/img/ytjs.svg" title="youtube.js" alt="YouTube.js' Github Page" width="200" /></a>
  </p>
  <p align="center">A full-featured wrapper around the InnerTube API</p>

  [![Discord](https://img.shields.io/badge/discord-online-brightgreen.svg)][discord]
  [![CI](https://github.com/LuanRT/YouTube.js/actions/workflows/test.yml/badge.svg)][actions]
  [![NPM Version](https://img.shields.io/npm/v/youtubei.js?color=%2335C757)][versions]
  [![Downloads](https://img.shields.io/npm/dt/youtubei.js)][npm]
  [![Codefactor](https://www.codefactor.io/repository/github/luanrt/youtube.js/badge)][codefactor]

  <h5>
  Sponsored by&nbsp;&nbsp;&nbsp;&nbsp;<a href="https://serpapi.com"><img src="https://luanrt.github.io/assets/img/serpapi.svg" alt="SerpApi - API to get search engine results with ease." height=35 valign="middle"></a>
  </h5>
  <br>
</div>

InnerTube is an API used by all YouTube clients. It was created to simplify the deployment of new features and experiments across the platform [^1]. This library manages all low-level communication with InnerTube, providing a simple and efficient way to interact with YouTube programmatically. Its design aims to closely emulate an actual client, including the parsing of API responses.

If you have any questions or need help, feel free to reach out to us on our [Discord server][discord] or open an issue [here](https://github.com/LuanRT/YouTube.js/issues).

### Table of Contents
<ol>
   <li><a href="#prerequisites">Prerequisites</a></li>
   <li><a href="#installation">Installation</a></li>
   <li>
      <a href="#usage">Usage</a>
      <ul>
         <li><a href="#browser-usage">Browser Usage</a></li>
         <li><a href="#caching">Caching</a></li>
         <li><a href="#api">API</a></li>
         <li><a href="#extending-the-library">Extending the library</a></li>
      </ul>
   </li>
   <li><a href="#contributing">Contributing</a></li>
   <li><a href="#contact">Contact</a></li>
   <li><a href="#disclaimer">Disclaimer</a></li>
   <li><a href="#license">License</a></li>
</ol>

### Prerequisites
YouTube.js runs on Node.js, Deno, and modern browsers.

It requires a runtime with the following features:
- [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API)
  - On Node, we use [undici](https://github.com/nodejs/undici)'s fetch implementation, which requires Node.js 16.8+. If you need to use an older version, you may provide your own fetch implementation. See [providing your own fetch implementation](#custom-fetch) for more information. 
  - The `Response` object returned by fetch must thus be spec compliant and return a `ReadableStream` object if you want to use the `VideoInfo#download` method. (Implementations like `node-fetch` return a non-standard `Readable` object.)
- [`EventTarget`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget) and [`CustomEvent`](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent) are required.

### Installation

```bash
# NPM
npm install youtubei.js@latest

# Yarn
yarn add youtubei.js@latest

# Git (edge version)
npm install github:LuanRT/YouTube.js

When using Deno, you can import YouTube.js directly from deno.land:

import { Innertube } from 'https://deno.land/x/youtubei/deno.ts';

Usage

Create an InnerTube instance:

// const { Innertube } = require('youtubei.js');
import { Innertube } from 'youtubei.js';
const youtube = await Innertube.create(/* options */);

Options

Click to expand
Option Type Description Default
lang string Language. en
location string Geolocation. US
account_index number The account index to use. This is useful if you have multiple accounts logged in. NOTE: Only works if you are signed in with cookies. 0
visitor_data string Setting this to a valid and persistent visitor data string will allow YouTube to give this session tailored content even when not logged in. A good way to get a valid one is by either grabbing it from a browser or calling InnerTube's /visitor_id endpoint. undefined
po_token string Proof of Origin Token. This is an attestation token generated by BotGuard/DroidGuard. It is used to confirm that the request is coming from a genuine device. To obtain a valid token, please refer to Invidious' Trusted Session Generator. undefined
retrieve_player boolean Specifies whether to retrieve the JS player. Disabling this will make session creation faster. NOTE: Deciphering formats is not possible without the JS player. true
enable_safety_mode boolean Specifies whether to enable safety mode. This will prevent the session from loading any potentially unsafe content. false
generate_session_locally boolean Specifies whether to generate the session data locally or retrieve it from YouTube. This can be useful if you need more performance. NOTE: If you are using the cache option and a session has already been generated, this will be ignored. If you want to force a new session to be generated, you must clear the cache or disable session caching. false
enable_session_cache boolean Specifies whether to cache the session data. true
device_category DeviceCategory Platform to use for the session. DESKTOP
client_type ClientType InnerTube client type. It is not recommended to change this unless you know what you are doing. WEB
timezone string The time zone. *
cache ICache Used to cache algorithms, session data, and OAuth2 tokens. undefined
cookie string YouTube cookies. undefined
fetch FetchFunction Fetch function to use. fetch

Browser Usage

To use YouTube.js in the browser, you must proxy requests through your own server. You can see our simple reference implementation in Deno at examples/browser/proxy/deno.ts.

You may provide your own fetch implementation to be used by YouTube.js, which we will use to modify and send the requests through a proxy. See examples/browser/web for a simple example using Vite.

// Multiple exports are available for the web.
// Unbundled ESM version
import { Innertube } from 'youtubei.js/web';
// Bundled ESM version
// import { Innertube } from 'youtubei.js/web.bundle';
// Production Bundled ESM version
// import { Innertube } from 'youtubei.js/web.bundle.min';
await Innertube.create({
  fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
    // Modify the request
    // and send it to the proxy

    // fetch the URL
    return fetch(request, init);
  }
});

Streaming

YouTube.js supports streaming of videos in the browser by converting YouTube's streaming data into an MPEG-DASH manifest.

The example below uses dash.js to play the video.

import { Innertube } from 'youtubei.js/web';
import dashjs from 'dashjs';

const youtube = await Innertube.create({ /* setup - see above */ });

// Get the video info
const videoInfo = await youtube.getInfo('videoId');

// now convert to a dash manifest
// again - to be able to stream the video in the browser - we must proxy the requests through our own server
// to do this, we provide a method to transform the URLs before writing them to the manifest
const manifest = await videoInfo.toDash(url => {
  // modify the url
  // and return it
  return url;
});

const uri = "data:application/dash+xml;charset=utf-8;base64," + btoa(manifest);

const videoElement = document.getElementById('video_player');

const player = dashjs.MediaPlayer().create();
player.initialize(videoElement, uri, true);

A fully working example can be found in examples/browser/web.

Providing your own fetch implementation

You may provide your own fetch implementation to be used by YouTube.js. This can be useful in some cases to modify the requests before they are sent and transform the responses before they are returned (eg. for proxies).

// provide a fetch implementation
const yt = await Innertube.create({
  fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
    // make the request with your own fetch implementation
    // and return the response
    return new Response(
      /* ... */
    );
  }
});

Caching

Caching the transformed player instance can greatly improve the performance. Our UniversalCache implementation uses different caching methods depending on the environment.

In Node.js, we use the node:fs module, Deno.writeFile() in Deno, and indexedDB in browsers.

By default, the cache stores data in the operating system's temporary directory (or indexedDB in browsers). You can make this cache persistent by specifying the path to the cache directory, which will be created if it doesn't exist.

import { Innertube, UniversalCache } from 'youtubei.js';
// Create a cache that stores files in the OS temp directory (or indexedDB in browsers) by default.
const yt = await Innertube.create({
  cache: new UniversalCache(false)
});

// You may want to create a persistent cache instead (on Node and Deno).
const yt = await Innertube.create({
  cache: new UniversalCache(
    // Enables persistent caching
    true, 
    // Path to the cache directory. The directory will be created if it doesn't exist
    './.cache' 
  )
});

API

getInfo(target, client?)

Retrieves video info.

Returns: Promise<VideoInfo>

Param Type Description
target string | NavigationEndpoint If string, the id of the video. If NavigationEndpoint, the endpoint of watchable elements such as Video, Mix and Playlist. To clarify, valid endpoints have payloads containing at least videoId and optionally playlistId, params and index.
client? InnerTubeClient InnerTube client to use.
Methods & Getters

  • <info>#like()

    • Likes the video.
  • <info>#dislike()

    • Dislikes the video.
  • <info>#removeRating()

    • Removes like/dislike.
  • <info>#getLiveChat()

    • Returns a LiveChat instance.
  • <info>#getTrailerInfo()

    • Returns trailer info in a new VideoInfo instance, or null if none. Typically available for non-purchased movies or films.
  • <info>#chooseFormat(options)

    • Used to choose streaming data formats.
  • <info>#toDash(url_transformer?, format_filter?)

    • Converts streaming data to an MPEG-DASH manifest.
  • <info>#download(options)

  • <info>#getTranscript()

    • Retrieves the video's transcript.
  • <info>#filters

    • Returns filters that can be applied to the watch next feed.
  • <info>#selectFilter(name)

    • Applies the given filter to the watch next feed and returns a new instance of VideoInfo.
  • <info>#getWatchNextContinuation()

    • Retrieves the next batch of items for the watch next feed.
  • <info>#addToWatchHistory()

    • Adds the video to the watch history.
  • <info>#autoplay_video_endpoint

    • Returns the endpoint of the video for Autoplay.
  • <info>#has_trailer

    • Checks if trailer is available.
  • <info>#page

    • Returns original InnerTube response (sanitized).

getBasicInfo(video_id, client?)

Suitable for cases where you only need basic video metadata. Also, it is faster than getInfo().

Returns: Promise<VideoInfo>

Param Type Description
video_id string The id of the video
client? InnerTubeClient InnerTube client to use.

search(query, filters?)

Searches the given query on YouTube.

Returns: Promise<Search>

Note Search extends the Feed class.

Param Type Description
query string The search query
filters? SearchFilters Search filters
Search Filters
Filter Type Value Description
upload_date string all | hour | today | week | month | year Filter by upload date
type string all | video | channel | playlist | movie Filter by type
duration string all | short | medium | long Filter by duration
sort_by string relevance | rating | upload_date | view_count Sort by
features string[] hd | subtitles | creative_commons | 3d | live | purchased | 4k | 360 | location | hdr | vr180 Filter by features
Methods & Getters

  • <search>#selectRefinementCard(SearchRefinementCard | string)

    • Applies given refinement card and returns a new Search instance.
  • <search>#refinement_card_queries

    • Returns available refinement cards, this is a simplified version of the refinement_cards object.
  • <search>#getContinuation()

    • Retrieves next batch of results.

getSearchSuggestions(query)

Retrieves search suggestions for given query.

Returns: Promise<string[]>

Param Type Description
query string The search query

getComments(video_id, sort_by?)

Retrieves comments for given video.

Returns: Promise<Comments>

Param Type Description
video_id string The video id
sort_by string Can be: TOP_COMMENTS or NEWEST_FIRST

See ./examples/comments for examples.

getHomeFeed()

Retrieves YouTube's home feed.

Returns: Promise<HomeFeed>

Note HomeFeed extends the FilterableFeed class.

Methods & Getters

  • <home_feed>#videos

    • Returns all videos in the home feed.
  • <home_feed>#posts

    • Returns all posts in the home feed.
  • <home_feed>#shelves

    • Returns all shelves in the home feed.
  • <home_feed>#filters

    • Returns available filters.
  • <home_feed>#applyFilter(name | ChipCloudChip)

    • Applies given filter and returns a new HomeFeed instance.
  • <home_feed>#getContinuation()

    • Retrieves feed continuation.

getGuide()

Retrieves YouTube's content guide.

Returns: Promise<Guide>

getLibrary()

Retrieves the account's library.

Returns: Promise<Library>

Note Library extends the Feed class.

Methods & Getters

  • <library>#history
  • <library>#watch_later
  • <library>#liked_videos
  • <library>#playlists_section
  • <library>#clips

getHistory()

Retrieves watch history.

Returns: Promise<History>

Note History extends the Feed class.

Methods & Getters

  • <history>#getContinuation()
    • Retrieves next batch of contents.

getTrending()

Retrieves trending content.

Returns: Promise<TabbedFeed<IBrowseResponse>>

getSubscriptionsFeed()

Retrieves the subscriptions feed.

Returns: Promise<Feed<IBrowseResponse>>

getChannel(id)

Retrieves contents for a given channel.

Returns: Promise<Channel>

Note Channel extends the TabbedFeed class.

Param Type Description
id string Channel id
Methods & Getters

  • <channel>#getVideos()
  • <channel>#getShorts()
  • <channel>#getLiveStreams()
  • <channel>#getReleases()
  • <channel>#getPodcasts()
  • <channel>#getPlaylists()
  • <channel>#getHome()
  • <channel>#getCommunity()
  • <channel>#getChannels()
  • <channel>#getAbout()
  • <channel>#search(query)
  • <channel>#applyFilter(filter)
  • <channel>#applyContentTypeFilter(content_type_filter)
  • <channel>#applySort(sort)
  • <channel>#getContinuation()
  • <channel>#filters
  • <channel>#content_type_filters
  • <channel>#sort_filters
  • <channel>#page

See ./examples/channel for examples.

getNotifications()

Retrieves notifications.

Returns: Promise<NotificationsMenu>

Methods & Getter

  • <notifications>#getContinuation()
    • Retrieves next batch of notifications.

getUnseenNotificationsCount()

Retrieves unseen notifications count.

Returns: Promise<number>

getPlaylist(id)

Retrieves playlist contents.

Returns: Promise<Playlist>

Note Playlist extends the Feed class.

Param Type Description
id string Playlist id
Methods & Getter

  • <playlist>#items
    • Returns the items of the playlist.

getHashtag(hashtag)

Retrieves a given hashtag's page.

Returns: Promise<HashtagFeed>

Note HashtagFeed extends the FilterableFeed class.

Param Type Description
hashtag string The hashtag
Methods & Getter

  • <hashtag>#applyFilter(filter)
    • Applies given filter and returns a new HashtagFeed instance.
  • <hashtag>#getContinuation()
    • Retrieves next batch of contents.

getStreamingData(video_id, options)

Returns deciphered streaming data.

Note This method will be deprecated in the future. We recommend retrieving streaming data from a VideoInfo or TrackInfo object instead if you want to select formats manually. Please refer to the following example:

const info = await yt.getBasicInfo('somevideoid');

const url = info.streaming_data?.formats[0].decipher(yt.session.player);
console.info('Playback url:', url);

// or:
const format = info.chooseFormat({ type: 'audio', quality: 'best' });
const url = format?.decipher(yt.session.player);
console.info('Playback url:', url);

Returns: Promise<object>

Param Type Description
video_id string Video id
options FormatOptions Format options

download(video_id, options?)

Downloads a given video.

Returns: Promise<ReadableStream<Uint8Array>>

Param Type Description
video_id string Video id
options DownloadOptions Download options

See ./examples/download for examples.

resolveURL(url)

Resolves a given url.

Returns: Promise<NavigationEndpoint>

Param Type Description
url string Url to resolve

call(endpoint, args?)

Utility to call navigation endpoints.

Returns: Promise<T extends IParsedResponse | IParsedResponse | ApiResponse>

Param Type Description
endpoint NavigationEndpoint The target endpoint
args? object Additional payload arguments

Extending the library

YouTube.js is modular and easy to extend. Most of the methods, classes, and utilities used internally are exposed and can be used to implement your own extensions without having to modify the library's source code.

For example, let's say we want to implement a method to retrieve video info. We can do that by using an instance of the Actions class:

import { Innertube } from 'youtubei.js';

(async () => {
  const yt = await Innertube.create();

  async function getVideoInfo(videoId: string) {
    const videoInfo = await yt.actions.execute('/player', {
      // You can add any additional payloads here, and they'll merge with the default payload sent to InnerTube.
      videoId,
      client: 'YTMUSIC', // InnerTube client to use.
      parse: true // tells YouTube.js to parse the response (not sent to InnerTube).
    });

    return videoInfo;
  }

  const videoInfo = await getVideoInfo('jLTOuvBTLxA');
  console.info(videoInfo);
})();

Alternatively, suppose we locate a NavigationEndpoint in a parsed response and want to see what happens when we call it:

import { Innertube, YTNodes } from 'youtubei.js';

(async () => {
  const yt = await Innertube.create();

  const artist = await yt.music.getArtist('UC52ZqHVQz5OoGhvbWiRal6g');
  const albums = artist.sections[1].as(YTNodes.MusicCarouselShelf);

  // Let's imagine that we wish to click on the β€œMore” button:
  const button = albums.as(YTNodes.MusicCarouselShelf).header?.more_content;

  if (button) {
    // Having ensured that it exists, we can then call its navigation endpoint using the following code:
    const page = await button.endpoint.call(yt.actions, { parse: true });
    console.info(page);
  }
})();

Parser

YouTube.js' parser enables you to parse InnerTube responses and convert their nodes into strongly-typed objects that are simple to manipulate. Additionally, it provides numerous utility methods that make working with InnerTube a breeze.

Here's an example of its usage:

// See ./examples/parser

import { Parser, YTNodes } from 'youtubei.js';
import { readFileSync } from 'fs';

// YouTube Music's artist page response
const data = readFileSync('./artist.json').toString();

const page = Parser.parseResponse(JSON.parse(data));

const header = page.header?.item().as(YTNodes.MusicImmersiveHeader, YTNodes.MusicVisualHeader);

console.info('Header:', header);

// The parser uses a proxy object to add type safety and utility methods for working with InnerTube's data arrays:
const tab = page.contents?.item().as(YTNodes.SingleColumnBrowseResults).tabs.firstOfType(YTNodes.Tab);

if (!tab)
  throw new Error('Target tab not found');

if (!tab.content)
  throw new Error('Target tab appears to be empty');
  
const sections = tab.content?.as(YTNodes.SectionList).contents.as(YTNodes.MusicCarouselShelf, YTNodes.MusicDescriptionShelf, YTNodes.MusicShelf);

console.info('Sections:', sections);

Documentation for the parser can be found here.

Contributing

We welcome all contributions, issues and feature requests, whether small or large. If you want to contribute, feel free to check out our issues page and our guidelines.

We are immensely grateful to all the wonderful people who have contributed to this project. A special shoutout to all our contributors! πŸŽ‰

Contact

LuanRT - [@thesciencephile][twitter] - [email protected]

Project Link: [https://github.com/LuanRT/YouTube.js][project]

Disclaimer

This project is not affiliated with, endorsed, or sponsored by YouTube or any of its affiliates or subsidiaries. All trademarks, logos, and brand names used in this project are the property of their respective owners and are used solely to describe the services provided.

As such, any usage of trademarks to refer to such services is considered nominative use. If you have any questions or concerns, please contact me directly via email.

License

Distributed under the MIT License.

(back to top)

```

LuanRT/YouTube.js/blob/main/bundle/browser.d.ts:

export * from '../dist/src/platform/lib.js';

LuanRT/YouTube.js/blob/main/deno.ts:

export * from './deno/src/platform/deno.ts';
import Innertube from './deno/src/platform/deno.ts';
export default Innertube;

LuanRT/YouTube.js/blob/main/dev-scripts/gen-parser-map.mjs:

import glob from 'glob';
import path from 'path';
import fs from 'fs';
import url from 'url';

const import_list = [];
const misc_imports = [];

const __dirname = path.dirname(url.fileURLToPath(import.meta.url));

glob.sync('../src/parser/classes/**/*.{js,ts}', { cwd: __dirname })
  .forEach((file) => {
    // Trim path
    const is_misc = file.includes('/misc/');
    file = file.replace('../src/parser/classes/', '').replace('.js', '').replace('.ts', '');
    const import_name = file.split('/').pop();

    if (is_misc) {
      const class_name = file.split('/').pop().replace('.js', '').replace('.ts', '');
      misc_imports.push(`export { default as ${class_name} } from './classes/${file}.js';`);
    } else {
      import_list.push(`export { default as ${import_name} } from './classes/${file}.js';`);
    }
  });

fs.writeFileSync(
  path.resolve(__dirname, '../src/parser/nodes.ts'),
  `// This file was auto generated, do not edit.
// See ./scripts/build-parser-map.js

${import_list.join('\n')}
`
);

fs.writeFileSync(
  path.resolve(__dirname, '../src/parser/misc.ts'),
  `// This file was auto generated, do not edit.
// See ./scripts/build-parser-map.js

${misc_imports.join('\n')}
`
);

LuanRT/YouTube.js/blob/main/dev-scripts/get-agents.mjs:

import { fetch } from 'undici';
import { gunzip } from 'zlib';
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
import { writeFile } from 'fs/promises';

const __dirname = dirname(fileURLToPath(import.meta.url));

const buf = await (await fetch('https://github.com/intoli/user-agents/blob/master/src/user-agents.json.gz?raw=true')).arrayBuffer();
const bytes = new Uint8Array(buf);

// Only get desktop and mobile agents
const allowed_agents = new Set([
  'desktop',
  'mobile'
]);

const decompressed = await new Promise((resolve, reject) => {
  gunzip(bytes, (err, result) => {
    if (err) {
      reject(err);
    } else {
      resolve(result.buffer);
    }
  });
});

const contents = new TextDecoder().decode(decompressed);

const agents = JSON.parse(contents);

if (!Array.isArray(agents)) {
  throw new Error('Invalid user-agents.json');
}

const agentsByDevice = agents.reduce((acc, agent) => {
  const device = agent.deviceCategory;
  if (!allowed_agents.has(device))
    return acc;
  if (!acc[device]) {
    acc[device] = [];
  }
  // We dont want to massive of a list of agents for each device
  if (acc[device].length <= 25) acc[device].push(agent.userAgent);
  return acc;
}, {});

await writeFile(resolve(__dirname, '..', 'src', 'utils', 'user-agents.ts'), `/* eslint-disable */\n/* Generated file do not edit */\nexport default ${JSON.stringify(agentsByDevice, null, 2)} as { desktop: string[], mobile: string[] };`);

LuanRT/YouTube.js/blob/main/docs/API/account.md:

# Account

YouTube account manager.

## API

* Account 
  * [.channel](#channel)
  * [.getInfo()](#getinfo)
  * [.getTimeWatched()](#gettimewatched)
  * [.getSettings()](#getsettings)
  * [.getAnalytics](#getanalytics)

<a name="channel"></a>
### channel

Channel settings.

**Returns:** `object`

<details>
<summary>Methods & Getters</summary>
<p>

- `<channel>#editName(new_name)`
  - Edits the name of the channel.

- `<channel>#editDescription(new_description)`
  - Edits channel description.

- `<channel>#getBasicAnalytics()`
  - Alias for [`Account#getAnalytics()`](#getanalytics) β€” returns basic channel analytics.

</p>
</details>

<a name="getinfo"></a>
### getInfo()

Retrieves account information.

**Returns:** `Promise.<AccountInfo>`

<details>
<summary>Methods & Getters</summary>
<p>

- `<accountinfo>#page`
  - Returns the original InnerTube response(s), parsed and sanitized.

</p>
</details>

<a name="gettimewatched"></a>
### getTimeWatched()

Retrieves time watched statistics.

**Returns:** `Promise.<TimeWatched>`

<details>
<summary>Methods & Getters</summary>
<p>

- `<timewatched>#page`
  - Returns the original InnerTube response(s), parsed and sanitized.

</p>
</details>

<a name="getsettings"></a>
### getSettings()

Retrieves YouTube settings.

**Returns:** `Promise.<Settings>`

<details>
<summary>Methods & Getters</summary>
<p>

- `<settings>#selectSidebarItem(name)`
  - Selects an item from the sidebar menu. Use `settings#sidebar_items` to see available items.

- `<settings>#getSettingOption(name)`
  - Finds a setting by name and returns it. Use `settings#setting_options` to see available options.

- `<settings>#setting_options`
  - Returns settings available in the page.

- `<settings>#sidebar_items`
  - Returns options available in the sidebar menu.

- `<settings>#page`
  - Returns the original InnerTube response(s), parsed and sanitized.

</p>
</details>

<a name="getanalytics"></a>
### getAnalytics()

Retrieves basic channel analytics.

**Returns:** `Promise.<Analytics>`

<details>
<summary>Methods & Getters</summary>
<p>

- `<analytics>#page`
  - Returns the original InnerTube response(s), parsed and sanitized.

</p>
</details>

LuanRT/YouTube.js/blob/main/docs/API/feed.md:

# Feed

Represents a YouTube feed. This class provides a set of utility methods for parsing and interacting with feeds.

## API 

* Feed
  * [.videos](#videos)
  * [.posts](#posts)
  * [.channels](#channels)
  * [.playlists](#playlists)
  * [.shelves](#shelves)
  * [.memo](#memo)
  * [.page_contents](#page_contents)
  * [.secondary_contents](#secondary_contents)
  * [.page](#page)
  * [.has_continuation](#has_continuation)
  * [.getContinuationData()](#getcontinuationdata)
  * [.getContinuation()](#getcontinuation)
  * [.getShelf(title)](#getshelf)

<a name="videos"></a>
### videos

Returns all videos in the feed.

**Returns:** `ObservedArray<Video | GridVideo | ReelItem | CompactVideo | PlaylistVideo | PlaylistPanelVideo | WatchCardCompactVideo>`

<a name="posts"></a>
### posts

Returns all posts in the feed.

**Returns:** `ObservedArray<Post | BackstagePost>`

<a name="channels"></a>
### channels

Returns all channels in the feed.

**Returns:** `ObservedArray<Channel | GridChannel>`

<a name="playlists"></a>
### playlists

Returns all playlists in the feed.

**Returns:** `ObservedArray<Playlist | GridPlaylist>`

<a name="shelves"></a>
### shelves

Returns all shelves in the feed.

**Returns:** `ObservedArray<Shelf | RichShelf | ReelShelf>`

<a name="memo"></a>
### memo

Returns the memoized feed contents.

**Returns:** `Memo`

<a name="page_contents"></a>
### page_contents

Returns the page contents.

**Returns:** `SectionList | MusicQueue | RichGrid | ReloadContinuationItemsCommand`

<a name="secondary_contents"></a>
### secondary_contents

Returns the secondary contents node.

**Returns:** `SuperParsedResult<YTNode> | undefined `

<a name="page"></a>
### page

Returns the original InnerTube response, parsed and sanitized.

**Returns:** `T extends IParsedResponse = IParsedResponse`

<a name="has_continuation"></a>
### has_continuation

Returns whether the feed has a continuation.

**Returns:** `boolean`

<a name="getcontinuationdata"></a>
### getContinuationData()

Returns the continuation data.

**Returns:** `Promise<T | undefined>`

<a name="getcontinuation"></a>
### getContinuation()

Retrieves the feed's continuation.

**Returns:** `Promise<Feed<T>>`

<a name="getshelf"></a>
### getShelf(title)

Gets a shelf by its title.

**Returns:** `Shelf | RichShelf | ReelShelf | undefined`

| Param | Type | Description |
| --- | --- | --- |
| title | `string` | The title of the shelf to get |

LuanRT/YouTube.js/blob/main/docs/API/filterable-feed.md:

# FilterableFeed

Represents a feed that can be filtered. 

> **Note** 
> This class extends the [Feed](feed.md) class.

## API

* FilterableFeed
  * [.filter_chips](#filter_chips)
  * [.filters](#filters)
  * [.getFilteredFeed(filter: string | ChipCloudChip)](#getfilteredfeed)

<a name="filter_chips"></a>
### filter_chips

Returns the feed's filter chips.

**Returns:** `ObservedArray<ChipCloudChip>`

<a name="filters"></a>
### filters

Returns the feed's filter chips as an array of strings.

**Returns:** `string[]`

<a name="getfilteredfeed"></a>
### getFilteredFeed(filter: string | ChipCloudChip)

Returns a new [Feed](feed.md) with the given filter applied.

**Returns:** `Promise<Feed<T>>`

| Param | Type | Description |
| --- | --- | --- |
| filter | `string` \| `ChipCloudChip` | The filter to apply |

LuanRT/YouTube.js/blob/main/docs/API/interaction-manager.md:

# InteractionManager

Handles direct interactions.

## API

* InteractionManager 
  * [.like(video_id)](#like) 
  * [.dislike(video_id)](#dislike) 
  * [.removeRating(video_id)](#removerating) 
  * [.subscribe(video_id)](#subscribe) 
  * [.unsubscribe(video_id)](#unsubscribe) 
  * [.comment(video_id, text)](#comment) 
  * [.translate(text, target_language, args?)](#translate) 
  * [.setNotificationPreferences(channel_id, type)](#setnotificationpreferences)

<a name="like"></a>
### like(video_id)

Likes given video.

**Returns:** `Promise.<ApiResponse>`

| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | Video id |

<a name="dislike"></a>
### dislike(video_id)

Dislikes given video.

**Returns:** `Promise.<ApiResponse>`

| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | Video id |

<a name="removerating"></a>
### removeRating(video_id)

Remover like/dislike.

**Returns:** `Promise.<ApiResponse>`

| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | Video id |

<a name="subscribe"></a>
### subscribe(channel_id)

Subscribes to given channel.

**Returns:** `Promise.<ApiResponse>`

| Param | Type | Description |
| --- | --- | --- |
| channel_id | `string` | Channel id |

<a name="unsubscribe"></a>
### unsubscribe(channel_id)

Unsubscribes from given channel.

**Returns:** `Promise.<ApiResponse>`

| Param | Type | Description |
| --- | --- | --- |
| channel_id | `string` | Channel id |

<a name="comment"></a>
### comment(video_id, text)

Posts a comment on given video.

**Returns:** `Promise.<ApiResponse>`

| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | Video id |
| text | `string` | Comment content |

<a name="translate"></a>
### translate(text, target_language, args?)

Translates given text using YouTube's comment translation feature.

**Returns:** `Promise.<ApiResponse>`

| Param | Type | Description |
| --- | --- | --- |
| text | `string` | Text to be translated |
| target_language | `string` | ISO language code |
| args? | `object` | Additional arguments |

<a name="setnotificationpreferences"></a>
### setNotificationPreferences(channel_id, type)

Changes notification preferences for a given channel.
Only works with channels you are subscribed to.

**Returns:** `Promise.<ApiResponse>`

| Param | Type | Description |
| --- | --- | --- |
| channel_id | `string` | Channel id |
| type | `string` | `PERSONALIZED`, `ALL` or `NONE` |

LuanRT/YouTube.js/blob/main/docs/API/kids.md:

# YouTube Kids

YouTube Kids is a modified version of the YouTube app, with a simplified interface and curated content. This class allows you to interact with its API.

## API
  
* Kids
  * [.search(query)](#search)
  * [.getInfo(video_id)](#getinfo)
  * [.getChannel(channel_id)](#getchannel)
  * [.getHomeFeed()](#gethomefeed)
  * [.blockChannel(channel_id)](#blockchannel)

<a name="search"></a>
### search(query)

Searches the given query on YouTube Kids.

**Returns:** `Promise.<Search>`

| Param | Type | Description |
| --- | --- | --- |
| query | `string` | The query to search |


<details>
<summary>Methods & Getters</summary>
<p>

- `<search>#page`
  - Returns the original InnerTube response(s), parsed and sanitized.

</p>
</details> 

<a name="getinfo"></a>
### getInfo(video_id)

Retrieves video info.

**Returns:** `Promise.<VideoInfo>`

| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | The video id |

<details>
<summary>Methods & Getters</summary>
<p>

- `<info>#toDash(url_transformer?, format_filter?)`
  - Generates a DASH manifest from the streaming data.

- `<info>#chooseFormat(options)`
  - Selects the format that best matches the given options. This method is used internally by `#download`.

- `<info>#download(options?)`
  - Downloads the video.

- `<info>#addToWatchHistory()`
  - Adds the video to the watch history.

- `<info>#page`
  - Returns the original InnerTube response(s), parsed and sanitized.

</p>
</details> 

<a name="getchannel"></a>
### getChannel(channel_id)

Retrieves channel info.

**Returns:** `Promise.<Channel>`

| Param | Type | Description |
| --- | --- | --- |
| channel_id | `string` | The channel id |

<details>
<summary>Methods & Getters</summary>
<p>

- `<channel>#getContinuation()`
  - Retrieves next batch of videos.

- `<channel>#has_continuation`
  - Returns whether there are more videos to retrieve.

- `<channel>#page`
  - Returns the original InnerTube response(s), parsed and sanitized.

</p>
</details>

<a name="gethomefeed"></a>
### getHomeFeed()

Retrieves the home feed.

**Returns:** `Promise.<HomeFeed>`

<details>
<summary>Methods & Getters</summary>
<p>

- `<feed>#selectCategoryTab(tab: string | KidsCategoryTab)`
  - Selects the given category tab.

- `<feed>#categories`
  - Returns available categories.

- `<feed>#page`
  - Returns the original InnerTube response(s), parsed and sanitized.

</details>

<a name="blockChannel"></a>
### blockChannel(channel_id)

Retrieves the list of supervised accounts that the signed-in user has access to and blocks the given channel for each of them.

**Returns:** `Promise.<ApiResponse[]>`

| Param | Type | Description |
| --- | --- | --- |
| channel_id | `string` | Channel id |  

LuanRT/YouTube.js/blob/main/docs/API/music.md:

# YouTube Music

YouTube Music is a music streaming service developed by YouTube, a subsidiary of Google. It provides a tailored interface for the service oriented towards music streaming, with a greater emphasis on browsing and discovery compared to its main service. This class allows you to interact with its API.

## API

* Music 
  * [.getInfo(target)](#getinfo)
  * [.search(query, filters?)](#search)
  * [.getHomeFeed()](#gethomefeed)
  * [.getExplore()](#getexplore)
  * [.getLibrary()](#getlibrary)
  * [.getArtist(artist_id)](#getartist)
  * [.getAlbum(album_id)](#getalbum)
  * [.getPlaylist(playlist_id)](#getplaylist)
  * [.getLyrics(video_id)](#getlyrics)
  * [.getUpNext(video_id, automix?)](#getupnext)
  * [.getRelated(video_id)](#getrelated)
  * [.getRecap()](#getrecap)
  * [.getSearchSuggestions(query)](#getsearchsuggestions)

<a name="getinfo"></a>
### getInfo(target)

Retrieves track info.

**Returns:** `Promise.<TrackInfo>`

| Param | Type | Description |
| --- | --- | --- |
| target | `string` or `MusicTwoRowItem` | video id or list item |

<details>
<summary>Methods & Getters</summary>
<p>

- `<info>#getTab(title)`
  - Retrieves contents of the given tab.

- `<info>#getUpNext(automix?)`
  - Retrieves up next.

- `<info>#getRelated()`
  - Retrieves related content.

- `<info>#getLyrics()`
  - Retrieves song lyrics.

- `<info>#available_tabs`
  - Returns available tabs.

- `<info>#toDash(url_transformer?, format_filter?)`
  - Generates a DASH manifest from the streaming data.

- `<info>#chooseFormat(options)`
  - Selects the format that best matches the given options. This method is used internally by `#download`.

- `<info>#download(options?)`
  - Downloads the track.

- `<info>#addToWatchHistory()`
  - Adds the song to the watch history.

- `<info>#page`
  - Returns the original InnerTube response(s), parsed and sanitized.

</p>
</details> 

<a name="search"></a>
### search(query, filters?)

Searches on YouTube Music.

**Returns:** `Promise.<Search>`

| Param | Type | Description |
| --- | --- | --- |
| query | `string` | Search query |
| filters? | `MusicSearchFilters` | Search filters |

<details>
<summary>Search Filters</summary>

| Filter | Type | Value | Description |
| --- | --- | --- | --- |
| type | `string` | `all`, `song`, `video`, `album`, `playlist`, `artist` | Search type |

</details>

<details>
<summary>Methods & Getters</summary>
<p>

- `<search>#getMore(shelf)`
  - Equivalent to clicking on the shelf to load more items.

- `<search>#getContinuation()`
  - Retrieves continuation, only works for individual sections or filtered results.

- `<search>#selectFilter(name)`
  - Applies given filter to the search.

- `<search>#has_continuation`
  - Checks if continuation is available.

- `<search>#filters`
  - Returns available filters.

- `<search>#songs`
  - Returns songs shelf.

- `<search>#videos`
  - Returns videos shelf.

- `<search>#albums`
  - Returns albums shelf.

- `<search>#artists`
  - Returns artists shelf.

- `<search>#playlists`
  - Returns songs shelf.

- `<search>#page`
  - Returns the original InnerTube response(s), parsed and sanitized.

</p>
</details> 

<a name="gethomefeed"></a>
### getHomeFeed()

Retrieves home feed.

**Returns:** `Promise.<HomeFeed>`

<details>
<summary>Methods & Getters</summary>
<p>

- `<homefeed>#getContinuation()`
  - Retrieves continuation, only works for individual sections or filtered results.

- `<homefeed>#has_continuation`
  - Checks if continuation is available.

- `<homefeed>#page`
  - Returns original InnerTube response (sanitized).

- `<homefeed>#page`
  - Returns the original InnerTube response(s), parsed and sanitized.

</p>
</details> 

<a name="getexplore"></a>
### getExplore()

Retrieves β€œExplore” feed.

**Returns:** `Promise.<Explore>`

<details>
<summary>Methods & Getters</summary>
<p>

- `<explore>#page`
  - Returns the original InnerTube response(s), parsed and sanitized.

</p>
</details> 

<a name="getlibrary"></a>
### getLibrary()

Retrieves library.

**Returns:** `Library`

<details>
<summary>Methods & Getters</summary>
<p>

- `<library>#applyFilter(filter)`
  - Applies given filter to the library.

- `<library>#applySort(sort_by)`
  - Applies given sort option to the library items.

- `<library>#getContinuation()`
  - Retrieves continuation of the library items.

- `<library>#has_continuation`
  - Checks if continuation is available.

- `<library>#filters`
  - Returns available filters.

- `<library>#sort_options`
  - Returns available sort options.

- `<library>#page`
  - Returns the original InnerTube response(s), parsed and sanitized.

</p>
</details> 

<a name="getartist"></a>
### getArtist(artist_id)

Retrieves artist's info & content.

**Returns:** `Promise.<Artist>`

| Param | Type | Description |
| --- | --- | --- |
| artist_id | `string` | Artist id |

<details>
<summary>Methods & Getters</summary>
<p>

- `<artist>#page`
  - Returns the original InnerTube response(s), parsed and sanitized.

</p>
</details> 

<a name="getalbum"></a>
### getAlbum(album_id)

Retrieves given album.

**Returns:** `Promise.<Album>`

| Param | Type | Description |
| --- | --- | --- |
| album_id | `string` | Album id |

<details>
<summary>Methods & Getters</summary>
<p>

- `<album>#page`
  - Returns the original InnerTube response(s), parsed and sanitized.

</p>
</details> 

<a name="getplaylist"></a>
### getPlaylist(playlist_id)

Retrieves given playlist.

**Returns:** `Promise.<Playlist>`

| Param | Type | Description |
| --- | --- | --- |
| playlist_id | `string` | Playlist id |

<details>
<summary>Methods & Getters</summary>
<p>

- `<playlist>#getRelated()`
  - Retrieves related playlists.

- `<playlist>#getSuggestions()`
  - Retrieves playlist suggestions.

- `<playlist>#getContinuation()`
  - Retrieves continuation.

- `<playlist>#has_continuation`
  - Checks if continuation is available.

- `<playlist>#page`
  - Returns the original InnerTube response(s), parsed and sanitized.

</p>
</details> 

<a name="getlyrics"></a>
### getLyrics(video_id)

Retrieves song lyrics.

**Returns:** `Promise.<MusicDescriptionShelf | undefined>`

| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | Video id |

<a name="getupnext"></a>
### getUpNext(video_id, automix?)

Retrieves up next content.

**Returns:** `Promise.<PlaylistPanel>`

| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | Video id |
| automix? | `boolean` | if automix should be fetched |

<a name="getrelated"></a>
### getRelated(video_id)

Retrieves related content.

**Returns:** `Promise.<Array.<MusicCarouselShelf | MusicDescriptionShelf>>`

| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | Video id |

<a name="getrecap"></a>
### getRecap()

Retrieves your YouTube Music recap.

**Returns:** `Promise.<Recap>`

<details>
<summary>Methods & Getters</summary>
<p>

- `<recap>#getPlaylist()`
  - Retrieves recap playlist.

- `<recap>#page`
  - Returns the original InnerTube response(s), parsed and sanitized.

</p>
</details> 

<a name="getsearchsuggestions"></a>
### getSearchSuggestions(query)

Retrieves search suggestions.

**Returns:** `Promise.<Array.<SearchSuggestion | HistorySuggestion>>`

| Param | Type | Description |
| --- | --- | --- |
| query | `string` | Search query |

LuanRT/YouTube.js/blob/main/docs/API/playlist.md:

# PlaylistManager

Playlist management class.

## API

* PlaylistManager
  * [.create(title, video_ids)](#create) 
  * [.delete(playlist_id)](#delete) 
  * [.addVideos(playlist_id, video_ids)](#addvideos) 
  * [.removeVideos(playlist_id, video_ids)](#removevideos) 
  * [.moveVideo(playlist_id, moved_video_id, predecessor_video_id)](#movevideo) 
  * [.setName(playlist_id, name)](#setname) 
  * [.setDescription(playlist_id, description)](#setdescription) 

<a name="create"></a>
### create(title, video_ids)

Creates a playlist.

**Returns:** `Promise.<ApiResponse>`

| Param | Type | Description |
| --- | --- | --- |
| title | `string` | Playlist name |
| video_ids | `string[]` | array of videos |

<a name="delete"></a>
### delete(playlist_id)

Deletes given playlist.

**Returns:** `Promise.<ApiResponse>`

| Param | Type | Description |
| --- | --- | --- |
| playlist_id | `string` | Playlist id |

<a name="addvideos"></a>
### addVideos(playlist_id, video_ids)

Adds videos to given playlist.

**Returns:** `Promise.<{ playlist_id: string; action_result: any[] }>`

| Param | Type | Description |
| --- | --- | --- |
| playlist_id | `string` | Playlist id |
| video_ids | `string` | array of videos |

<a name="removevideos"></a>
### removeVideos(playlist_id, video_ids)

Removes videos from given playlist.

**Returns:** `Promise.<{ playlist_id: string; action_result: any[] }>`

| Param | Type | Description |
| --- | --- | --- |
| playlist_id | `string` | Playlist id |
| video_ids | `string` | array of videos |

<a name="movevideo"></a>
### moveVideo(playlist_id, moved_video_id, predecessor_video_id)

Moves a video to a new position within a given playlist.

**Returns:** `Promise.<{ playlist_id: string; action_result: any[] }>`

| Param | Type | Description |
| --- | --- | --- |
| playlist_id | `string` | Playlist id |
| moved_video_id | `string` | the video to be moved |
| predecessor_video_id | `string` | the video present in the target position |

<a name="setname"></a>
### setName(playlist_id, name)

Sets the name / title for the given playlist.

**Returns:** `Promise.<ApiResponse>`

| Param | Type | Description |
| --- | --- | --- |
| playlist_id | `string` | Playlist id |
| name | `string` | Name / title |


<a name="setdescription"></a>
### setDescription(playlist_id, description)

Sets the description for the given playlist.

**Returns:** `Promise.<ApiResponse>`

| Param | Type | Description |
| --- | --- | --- |
| playlist_id | `string` | Playlist id |
| description | `string` | Description |

LuanRT/YouTube.js/blob/main/docs/API/session.md:

# Session

Represents an InnerTube session.

## API

* Session
  * [.signIn(credentials?)](#signin) β‡’ `function`
  * [.signOut()](#signout) β‡’ `function`
  * [.key](#key) β‡’ `getter`
  * [.api_version](#api_version) β‡’ `getter`
  * [.client_version](#client_version) β‡’ `getter`
  * [.client_name](#client_name) β‡’ `getter`
  * [.context](#context) β‡’ `getter`
  * [.player](#player) β‡’ `getter`
  * [.lang](#lang) β‡’ `getter`

<a name="signin"></a>
### signIn(credentials?)

Signs in with given credentials. 

**Returns:** `Promise<void>`

| Param | Type | Description |
| --- | --- | --- |
| credentials? | `Credentials` | OAuth credentials |

<a name="signout"></a>
### signOut()

Signs out of the current account.

**Returns:** `Promise<ActionsResponse>`

<a name="key"></a>
### key

InnerTube API key.

**Returns:** `string`

<a name="api_version"></a>
### api_version

InnerTube API version.

**Returns:** `string`

<a name="client_version"></a>
### client_version

InnerTube client version.

**Returns:** `string`

<a name="client_name"></a>
### client_name

InnerTube client name.

**Returns:** `string`

<a name="context"></a>
### context

InnerTube context.

**Returns:** `Context`

<a name="player"></a>
### player

Player script object.

**Returns:** `Player`

<a name="lang"></a>
### lang

Client language.

**Returns:** `string`

LuanRT/YouTube.js/blob/main/docs/API/studio.md:

# Studio

YouTube Studio class (WIP).

## API

* Studio 
  * [.setThumbnail(video_id, buffer)](#setthumbnail)
  * [.updateVideoMetadata(video_id, metadata)](#updatemetadata)
  * [.upload(file, metadata)](#upload)

<a name="setthumbnail"></a>
### setThumbnail(video_id, buffer)

Uploads a custom thumbnail and sets it for a video.

**Returns:** `Promise.<ApiResponse>`

| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | Video id |
| buffer | `Uint8Array` | Thumbnail buffer |

<a name="updatemetadata"></a>
### updateVideoMetadata(video_id, metadata)

Updates given video's metadata.

**Returns:** `Promise.<ApiResponse>`

| Param | Type | Description |
| --- | --- | --- |
| video_id | `string` | Video id |
| metadata | `VideoMetadata` | Video metadata |

<a name="upload"></a>
### upload(file, metadata)

Uploads a video to YouTube.

**Returns:** `Promise.<ApiResponse>`

| Param | Type | Description |
| --- | --- | --- |
| file | `BodyInit` | Video file |
| metadata | `UploadedVideoMetadata` | Video metadata |

LuanRT/YouTube.js/blob/main/docs/API/tabbed-feed.md:

# TabbedFeed

Represents a feed with tabs. 

> **Note**
> This class extends the [Feed](feed.md) class.

## API

* TabbedFeed
  * [.tabs](#tabs)
  * [.getTabByName(title: string)](#gettabbyname)
  * [.getTabByURL(url: string)](#gettabbyurl)
  * [.hasTabWithURL(url: string)](#hastabwithurl)
  * [.title](#title)

<a name="tabs"></a>
### tabs

Returns the feed's tabs as an array of strings.

**Returns:** `string[]`

<a name="gettabbyname"></a>
### getTabByName(title: string)

Fetches a tab by its title.

**Returns:** `Promise<TabbedFeed<T>>`

| Param | Type | Description |
| --- | --- | --- |
| title | `string` | The title of the tab to get |

<a name="gettabbyurl"></a>
### getTabByURL(url: string)

Fetches a tab by its URL.

**Returns:** `Promise<TabbedFeed<T>>`

| Param | Type | Description |
| --- | --- | --- |
| url | `string` | The URL of the tab to get |

<a name="hastabwithurl"></a>
### hasTabWithURL(url: string)

Returns whether the feed has a tab with the given URL.

**Returns:** `boolean`

| Param | Type | Description |
| --- | --- | --- |
| url | `string` | The URL to check |

<a name="title"></a>
### title

Returns the currently selected tab's title.

**Returns:** `string | undefined`

LuanRT/YouTube.js/blob/main/docs/updating-the-parser.md:

# Updating the Parser

YouTube is constantly changing, so it is not uncommon to see YouTube crawlers/scrapers breaking every now and then.

Our parser, on the other hand, was written so that it behaves similarly to an official client, parsing and mapping renderers (also known as YTNodes) dynamically without hard-coding their path in the response. This way, whenever a new renderer pops up (e.g., when YouTube adds a new feature or makes a minor UI change), the library will print a warning similar to this:

SomeRenderer not found! This is a bug, want to help us fix it? Follow the instructions at https://github.com/LuanRT/YouTube.js/blob/main/docs/updating-the-parser.md or report it at https://github.com/LuanRT/YouTube.js/issues! Introspected and JIT generated this class in the meantime: class SomeRenderer extends YTNode { static type = 'SomeRenderer';

// ...

constructor(data: RawNode) { super(); // ... } }


This warning **does not** throw an error. The parser itself will continue working normally, even if a parsing error occurs in an existing renderer parser.

## Adding a New Renderer Parser

Thanks to the modularity of the parser, a renderer can be implemented by simply adding a new file anywhere in the [classes directory](../src/parser/classes)!

For example, suppose we have found a new renderer named `verticalListRenderer`. In that case, to let the parser know it exists at compile-time, we would have to create a file with the following structure:

> `../classes/VerticalList.ts`

```ts
import { Parser, RawNode } from '../index.js';
import { YTNode } from '../helpers.js';

export default class VerticalList extends YTNode {
  static type = 'VerticalList';

  header;
  contents;

  constructor(data: RawNode) {
    super();
    // parse the data here, ex;
    this.header = Parser.parseItem(data.header);
    this.contents = Parser.parseArray(data.contents);
  }
}

You may use the parser's generated class for the new renderer as a starting point for your own implementation.

Then update the parser map:

npm run build:parser-map

And that's it!


LuanRT/YouTube.js/blob/main/jest.config.js:

```js
/** @type {import('ts-jest').JestConfigWithTsJest} */
export default {
  preset: 'ts-jest',
  testEnvironment: 'node',
  transform: { '^.+\\.(ts|tsx)$': 'ts-jest' },
  testTimeout: 30000,
  moduleFileExtensions: [ 'ts', 'tsx', 'js' ],
  testMatch: [ '**/*.test.ts' ],
  setupFiles: []
};

LuanRT/YouTube.js/blob/main/package.json:

{
  "name": "youtubei.js",
  "version": "10.3.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",
  "typesVersions": {
    "*": {
      "agnostic": [
        "./dist/src/platform/lib.d.ts"
      ],
      "web": [
        "./dist/src/platform/lib.d.ts"
      ],
      "react-native": [
        "./dist/src/platform/lib.d.ts"
      ],
      "web.bundle": [
        "./dist/src/platform/lib.d.ts"
      ],
      "web.bundle.min": [
        "./dist/src/platform/lib.d.ts"
      ],
      "cf-worker": [
        "./dist/src/platform/lib.d.ts"
      ]
    }
  },
  "exports": {
    ".": {
      "node": {
        "import": "./dist/src/platform/node.js",
        "require": "./bundle/node.cjs"
      },
      "deno": "./dist/src/platform/deno.js",
      "types": "./dist/src/platform/lib.d.ts",
      "browser": "./dist/src/platform/web.js",
      "react-native": "./dist/src/platform/react-native.js",
      "default": "./dist/src/platform/web.js"
    },
    "./agnostic": {
      "types": "./dist/src/platform/lib.d.ts",
      "default": "./dist/src/platform/lib.js"
    },
    "./web": {
      "types": "./dist/src/platform/lib.d.ts",
      "default": "./dist/src/platform/web.js"
    },
    "./react-native": {
      "types": "./dist/src/platform/lib.d.ts",
      "default": "./dist/src/platform/react-native.js"
    },
    "./web.bundle": {
      "types": "./dist/src/platform/lib.d.ts",
      "default": "./bundle/browser.js"
    },
    "./web.bundle.min": {
      "types": "./dist/src/platform/lib.d.ts",
      "default": "./bundle/browser.min.js"
    },
    "./cf-worker": {
      "types": "./dist/src/platform/lib.d.ts",
      "default": "./dist/src/platform/cf-worker.js"
    }
  },
  "author": "LuanRT <[email protected]> (https://github.com/LuanRT)",
  "funding": [
    "https://github.com/sponsors/LuanRT"
  ],
  "contributors": [
    "Wykerd (https://github.com/wykerd/)",
    "MasterOfBob777 (https://github.com/MasterOfBob777)",
    "patrickkfkan (https://github.com/patrickkfkan)",
    "akkadaska (https://github.com/akkadaska)",
    "Absidue (https://github.com/absidue)"
  ],
  "directories": {
    "test": "./test",
    "examples": "./examples",
    "dist": "./dist"
  },
  "scripts": {
    "test": "npx jest --verbose",
    "lint": "npx eslint ./src",
    "lint:fix": "npx eslint --fix ./src",
    "clean": "npx rimraf ./dist/src ./dist/package.json ./bundle/browser.js ./bundle/browser.js.map ./bundle/browser.min.js ./bundle/browser.min.js.map ./bundle/node.cjs ./bundle/node.cjs.map ./bundle/cf-worker.js ./bundle/cf-worker.js.map ./bundle/react-native.js ./bundle/react-native.js.map ./deno",
    "build": "npm run clean && npm run build:parser-map && npm run build:proto && npm run build:esm && npm run bundle:node && npm run bundle:browser && npm run bundle:browser:prod && npm run bundle:cf-worker && npm run bundle:react-native",
    "build:parser-map": "node ./dev-scripts/gen-parser-map.mjs",
    "build:proto": "npx pb-gen-ts --entry-path=\"src/proto\" --out-dir=\"src/proto/generated\" --ext-in-import=\".js\"",
    "build:esm": "npx tspc",
    "build:deno": "npx cpy ./src ./deno && npx esbuild ./src/utils/DashManifest.tsx --keep-names --format=esm --platform=neutral --target=es2020 --outfile=./deno/src/utils/DashManifest.js && npx cpy ./package.json ./deno && npx replace \".js';\" \".ts';\" ./deno -r && npx replace '.js\";' '.ts\";' ./deno -r && npx replace \"'./DashManifest.ts';\" \"'./DashManifest.js';\" ./deno -r && npx replace \"'jintr';\" \"'https://esm.sh/jintr';\" ./deno -r",
    "bundle:node": "npx esbuild ./dist/src/platform/node.js --bundle --target=node10 --keep-names --format=cjs --platform=node --outfile=./bundle/node.cjs --external:jintr --external:undici --external:linkedom --external:tslib --sourcemap --banner:js=\"/* eslint-disable */\"",
    "bundle:browser": "npx esbuild ./dist/src/platform/web.js --banner:js=\"/* eslint-disable */\" --bundle --target=chrome58 --keep-names --format=esm --sourcemap --define:global=globalThis --conditions=module --outfile=./bundle/browser.js --platform=browser",
    "bundle:react-native": "npx esbuild ./dist/src/platform/react-native.js --bundle --target=es2020 --keep-names --format=esm --platform=neutral --sourcemap --define:global=globalThis --conditions=module --outfile=./bundle/react-native.js",
    "bundle:browser:prod": "npm run bundle:browser -- --outfile=./bundle/browser.min.js --minify",
    "bundle:cf-worker": "npx esbuild ./dist/src/platform/cf-worker.js --banner:js=\"/* eslint-disable */\" --bundle --target=es2020 --keep-names --format=esm --sourcemap --define:global=globalThis --conditions=module --outfile=./bundle/cf-worker.js --platform=node",
    "prepare": "npm run build",
    "watch": "npx tsc --watch"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/LuanRT/YouTube.js.git"
  },
  "license": "MIT",
  "dependencies": {
    "jintr": "^2.1.1",
    "tslib": "^2.5.0",
    "undici": "^5.19.1"
  },
  "overrides": {
    "typescript": "^5.0.0"
  },
  "devDependencies": {
    "@types/glob": "^8.1.0",
    "@types/jest": "^28.1.7",
    "@types/node": "^17.0.45",
    "@typescript-eslint/eslint-plugin": "^5.30.6",
    "@typescript-eslint/parser": "^5.30.6",
    "cpy-cli": "^4.2.0",
    "esbuild": "^0.14.49",
    "eslint": "^8.19.0",
    "eslint-plugin-tsdoc": "^0.2.16",
    "glob": "^8.0.3",
    "jest": "^29.7.0",
    "pbkit": "^0.0.59",
    "replace": "^1.2.2",
    "ts-jest": "^29.1.4",
    "ts-patch": "^3.0.2",
    "ts-transformer-inline-file": "^0.2.0",
    "typescript": "^5.0.0"
  },
  "bugs": {
    "url": "https://github.com/LuanRT/YouTube.js/issues"
  },
  "homepage": "https://github.com/LuanRT/YouTube.js#readme",
  "keywords": [
    "yt",
    "dl",
    "ytdl",
    "youtube",
    "youtubedl",
    "youtube-dl",
    "youtube-downloader",
    "youtube-music",
    "youtube-studio",
    "innertube",
    "unofficial",
    "downloader",
    "livechat",
    "studio",
    "upload",
    "ytmusic",
    "search",
    "music",
    "api"
  ]
}

LuanRT/YouTube.js/blob/main/src/Innertube.ts:

import Session from './core/Session.js';
import { Kids, Music, Studio } from './core/clients/index.js';
import { AccountManager, InteractionManager, PlaylistManager } from './core/managers/index.js';
import { Feed, TabbedFeed } from './core/mixins/index.js';

import {
  BrowseEndpoint,
  GetNotificationMenuEndpoint,
  GuideEndpoint,
  NextEndpoint,
  PlayerEndpoint,
  ResolveURLEndpoint,
  SearchEndpoint,
  Reel,
  Notification
} from './core/endpoints/index.js';

import {
  Channel,
  Comments,
  Guide,
  HashtagFeed,
  History,
  HomeFeed,
  Library,
  NotificationsMenu,
  Playlist,
  Search,
  VideoInfo
} from './parser/youtube/index.js';

import { ShortFormVideoInfo } from './parser/ytshorts/index.js';

import NavigationEndpoint from './parser/classes/NavigationEndpoint.js';

import * as Proto from './proto/index.js';
import * as Constants from './utils/Constants.js';
import { InnertubeError, generateRandomString, throwIfMissing } from './utils/Utils.js';

import type { ApiResponse } from './core/Actions.js';
import type { INextRequest } from './types/index.js';
import type { IBrowseResponse, IParsedResponse } from './parser/types/index.js';
import type { DownloadOptions, FormatOptions } from './types/FormatUtils.js';
import type { SessionOptions } from './core/Session.js';
import type Format from './parser/classes/misc/Format.js';

export type InnertubeConfig = SessionOptions;

export type InnerTubeClient = 'IOS' | 'WEB' | 'ANDROID' | 'YTMUSIC' | 'YTMUSIC_ANDROID' | 'YTSTUDIO_ANDROID' | 'TV_EMBEDDED' | 'YTKIDS';

export type SearchFilters = Partial<{
  upload_date: 'all' | 'hour' | 'today' | 'week' | 'month' | 'year';
  type: 'all' | 'video' | 'channel' | 'playlist' | 'movie';
  duration: 'all' | 'short' | 'medium' | 'long';
  sort_by: 'relevance' | 'rating' | 'upload_date' | 'view_count';
  features: ('hd' | 'subtitles' | 'creative_commons' | '3d' | 'live' | 'purchased' | '4k' | '360' | 'location' | 'hdr' | 'vr180')[];
}>;

/**
 * Provides access to various services and modules in the YouTube API.
 */
export default class Innertube {
  #session: Session;

  constructor(session: Session) {
    this.#session = session;
  }

  static async create(config: InnertubeConfig = {}): Promise<Innertube> {
    return new Innertube(await Session.create(config));
  }

  async getInfo(target: string | NavigationEndpoint, client?: InnerTubeClient): Promise<VideoInfo> {
    throwIfMissing({ target: target });

    let next_payload: INextRequest;

    if (target instanceof NavigationEndpoint) {
      next_payload = NextEndpoint.build({
        video_id: target.payload?.videoId,
        playlist_id: target.payload?.playlistId,
        params: target.payload?.params,
        playlist_index: target.payload?.index
      });
    } else if (typeof target === 'string') {
      next_payload = NextEndpoint.build({
        video_id: target
      });
    } else {
      throw new InnertubeError('Invalid target. Expected a video id or NavigationEndpoint.', target);
    }

    if (!next_payload.videoId)
      throw new InnertubeError('Video id cannot be empty', next_payload);

    const player_payload = PlayerEndpoint.build({
      video_id: next_payload.videoId,
      playlist_id: next_payload?.playlistId,
      client: client,
      sts: this.#session.player?.sts,
      po_token: this.#session.po_token
    });

    const player_response = this.actions.execute(PlayerEndpoint.PATH, player_payload);
    const next_response = this.actions.execute(NextEndpoint.PATH, next_payload);
    const response = await Promise.all([ player_response, next_response ]);

    const cpn = generateRandomString(16);

    return new VideoInfo(response, this.actions, cpn);
  }

  async getBasicInfo(video_id: string, client?: InnerTubeClient): Promise<VideoInfo> {
    throwIfMissing({ video_id });

    const response = await this.actions.execute(
      PlayerEndpoint.PATH, PlayerEndpoint.build({
        video_id: video_id,
        client: client,
        sts: this.#session.player?.sts,
        po_token: this.#session.po_token
      })
    );

    const cpn = generateRandomString(16);

    return new VideoInfo([ response ], this.actions, cpn);
  }

  async getShortsVideoInfo(video_id: string, client?: InnerTubeClient): Promise<ShortFormVideoInfo> {
    throwIfMissing({ video_id });

    const watch_response = this.actions.execute(
      Reel.ReelItemWatchEndpoint.PATH, Reel.ReelItemWatchEndpoint.build({ video_id, client })
    );

    const sequence_response = this.actions.execute(
      Reel.ReelWatchSequenceEndpoint.PATH, Reel.ReelWatchSequenceEndpoint.build({
        sequence_params: Proto.encodeReelSequence(video_id)
      })
    );

    const response = await Promise.all([ watch_response, sequence_response ]);

    const cpn = generateRandomString(16);

    return new ShortFormVideoInfo([ response[0] ], this.actions, cpn, response[1]);
  }

  async search(query: string, filters: SearchFilters = {}): Promise<Search> {
    throwIfMissing({ query });

    const response = await this.actions.execute(
      SearchEndpoint.PATH, SearchEndpoint.build({
        query, params: filters ? Proto.encodeSearchFilters(filters) : undefined
      })
    );

    return new Search(this.actions, response);
  }

  async getSearchSuggestions(query: string): Promise<string[]> {
    throwIfMissing({ query });

    const url = new URL(`${Constants.URLS.YT_SUGGESTIONS}search`);
    url.searchParams.set('q', query);
    url.searchParams.set('hl', this.#session.context.client.hl);
    url.searchParams.set('gl', this.#session.context.client.gl);
    url.searchParams.set('ds', 'yt');
    url.searchParams.set('client', 'youtube');
    url.searchParams.set('xssi', 't');
    url.searchParams.set('oe', 'UTF');

    const response = await this.#session.http.fetch(url);
    const response_data = await response.text();

    const data = JSON.parse(response_data.replace(')]}\'', ''));
    const suggestions = data[1].map((suggestion: any) => suggestion[0]);

    return suggestions;
  }

  async getComments(video_id: string, sort_by?: 'TOP_COMMENTS' | 'NEWEST_FIRST'): Promise<Comments> {
    throwIfMissing({ video_id });

    const response = await this.actions.execute(
      NextEndpoint.PATH, NextEndpoint.build({
        continuation: Proto.encodeCommentsSectionParams(video_id, {
          sort_by: sort_by || 'TOP_COMMENTS'
        })
      })
    );

    return new Comments(this.actions, response.data);
  }

  async getHomeFeed(): Promise<HomeFeed> {
    const response = await this.actions.execute(
      BrowseEndpoint.PATH, BrowseEndpoint.build({ browse_id: 'FEwhat_to_watch' })
    );
    return new HomeFeed(this.actions, response);
  }

  /**
   * Retrieves YouTube's content guide.
   */
  async getGuide(): Promise<Guide> {
    const response = await this.actions.execute(GuideEndpoint.PATH);
    return new Guide(response.data);
  }

  async getLibrary(): Promise<Library> {
    const response = await this.actions.execute(
      BrowseEndpoint.PATH, BrowseEndpoint.build({ browse_id: 'FElibrary' })
    );
    return new Library(this.actions, response);
  }

  async getHistory(): Promise<History> {
    const response = await this.actions.execute(
      BrowseEndpoint.PATH, BrowseEndpoint.build({ browse_id: 'FEhistory' })
    );
    return new History(this.actions, response);
  }

  async getTrending(): Promise<TabbedFeed<IBrowseResponse>> {
    const response = await this.actions.execute(
      BrowseEndpoint.PATH, { ...BrowseEndpoint.build({ browse_id: 'FEtrending' }), parse: true }
    );
    return new TabbedFeed(this.actions, response);
  }

  async getSubscriptionsFeed(): Promise<Feed<IBrowseResponse>> {
    const response = await this.actions.execute(
      BrowseEndpoint.PATH, { ...BrowseEndpoint.build({ browse_id: 'FEsubscriptions' }), parse: true }
    );
    return new Feed(this.actions, response);
  }

  async getChannelsFeed(): Promise<Feed<IBrowseResponse>> {
    const response = await this.actions.execute(
      BrowseEndpoint.PATH, { ...BrowseEndpoint.build({ browse_id: 'FEchannels' }), parse: true }
    );
    return new Feed(this.actions, response);
  }

  async getChannel(id: string): Promise<Channel> {
    throwIfMissing({ id });
    const response = await this.actions.execute(
      BrowseEndpoint.PATH, BrowseEndpoint.build({ browse_id: id })
    );
    return new Channel(this.actions, response);
  }

  async getNotifications(): Promise<NotificationsMenu> {
    const response = await this.actions.execute(
      GetNotificationMenuEndpoint.PATH, GetNotificationMenuEndpoint.build({
        notifications_menu_request_type: 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX'
      })
    );
    return new NotificationsMenu(this.actions, response);
  }

  async getUnseenNotificationsCount(): Promise<number> {
    const response = await this.actions.execute(Notification.GetUnseenCountEndpoint.PATH);
    // FIXME: properly parse this.
    return response.data?.unseenCount || response.data?.actions?.[0].updateNotificationsUnseenCountAction?.unseenCount || 0;
  }

  /**
   * Retrieves the user's playlists.
   */
  async getPlaylists(): Promise<Feed<IBrowseResponse>> {
    const response = await this.actions.execute(
      BrowseEndpoint.PATH, { ...BrowseEndpoint.build({ browse_id: 'FEplaylist_aggregation' }), parse: true }
    );
    return new Feed(this.actions, response);
  }

  async getPlaylist(id: string): Promise<Playlist> {
    throwIfMissing({ id });

    if (!id.startsWith('VL')) {
      id = `VL${id}`;
    }

    const response = await this.actions.execute(
      BrowseEndpoint.PATH, BrowseEndpoint.build({ browse_id: id })
    );

    return new Playlist(this.actions, response);
  }

  async getHashtag(hashtag: string): Promise<HashtagFeed> {
    throwIfMissing({ hashtag });

    const response = await this.actions.execute(
      BrowseEndpoint.PATH, BrowseEndpoint.build({
        browse_id: 'FEhashtag',
        params: Proto.encodeHashtag(hashtag)
      })
    );

    return new HashtagFeed(this.actions, response);
  }

  /**
   * An alternative to {@link download}.
   * Returns deciphered streaming data.
   *
   * If you wish to retrieve the video info too, have a look at {@link getBasicInfo} or {@link getInfo}.
   * @param video_id - The video id.
   * @param options - Format options.
   */
  async getStreamingData(video_id: string, options: FormatOptions = {}): Promise<Format> {
    const info = await this.getBasicInfo(video_id);

    const format = info.chooseFormat(options);
    format.url = format.decipher(this.#session.player);

    return format;
  }

  /**
   * Downloads a given video. If all you need the direct download link, see {@link getStreamingData}.
   * If you wish to retrieve the video info too, have a look at {@link getBasicInfo} or {@link getInfo}.
   * @param video_id - The video id.
   * @param options - Download options.
   */
  async download(video_id: string, options?: DownloadOptions): Promise<ReadableStream<Uint8Array>> {
    const info = await this.getBasicInfo(video_id, options?.client);
    return info.download(options);
  }

  /**
   * Resolves the given URL.
   * @param url - The URL.
   */
  async resolveURL(url: string): Promise<NavigationEndpoint> {
    const response = await this.actions.execute(
      ResolveURLEndpoint.PATH, { ...ResolveURLEndpoint.build({ url }), parse: true }
    );

    if (!response.endpoint)
      throw new InnertubeError('Failed to resolve URL. Expected a NavigationEndpoint but got undefined', response);

    return response.endpoint;
  }

  /**
   * Utility method to call an endpoint without having to use {@link Actions}.
   * @param endpoint -The endpoint to call.
   * @param args - Call arguments.
   */
  call<T extends IParsedResponse>(endpoint: NavigationEndpoint, args: { [key: string]: any; parse: true }): Promise<T>;
  call(endpoint: NavigationEndpoint, args?: { [key: string]: any; parse?: false }): Promise<ApiResponse>;
  call(endpoint: NavigationEndpoint, args?: object): Promise<IParsedResponse | ApiResponse> {
    return endpoint.call(this.actions, args);
  }

  /**
   * An interface for interacting with YouTube Music.
   */
  get music() {
    return new Music(this.#session);
  }

  /**
   * An interface for interacting with YouTube Studio.
   */
  get studio() {
    return new Studio(this.#session);
  }

  /**
   * An interface for interacting with YouTube Kids.
   */
  get kids() {
    return new Kids(this.#session);
  }

  /**
   * An interface for managing and retrieving account information.
   */
  get account() {
    return new AccountManager(this.#session.actions);
  }

  /**
   * An interface for managing playlists.
   */
  get playlist() {
    return new PlaylistManager(this.#session.actions);
  }

  /**
   * An interface for directly interacting with certain YouTube features.
   */
  get interact() {
    return new InteractionManager(this.#session.actions);
  }

  /**
   * An internal class used to dispatch requests.
   */
  get actions() {
    return this.#session.actions;
  }

  /**
   * The session used by this instance.
   */
  get session() {
    return this.#session;
  }
}

LuanRT/YouTube.js/blob/main/src/core/Actions.ts:

import { Parser, NavigateAction } from '../parser/index.js';
import { InnertubeError } from '../utils/Utils.js';

import type { Session } from './index.js';

import type {
  IBrowseResponse, IGetNotificationsMenuResponse,
  INextResponse, IPlayerResponse, IResolveURLResponse,
  ISearchResponse, IUpdatedMetadataResponse,
  IParsedResponse, IRawResponse
} from '../parser/types/index.js';

export interface ApiResponse {
  success: boolean;
  status_code: number;
  data: IRawResponse;
}

export type InnertubeEndpoint = '/player' | '/search' | '/browse' | '/next' | '/reel' | '/updated_metadata' | '/notification/get_notification_menu' | string;

export type ParsedResponse<T> =
  T extends '/player' ? IPlayerResponse :
  T extends '/search' ? ISearchResponse :
  T extends '/browse' ? IBrowseResponse :
  T extends '/next' ? INextResponse :
  T extends '/updated_metadata' ? IUpdatedMetadataResponse :
  T extends '/navigation/resolve_url' ? IResolveURLResponse :
  T extends '/notification/get_notification_menu' ? IGetNotificationsMenuResponse :
  IParsedResponse;

export default class Actions {
  session: Session;

  constructor(session: Session) {
    this.session = session;
  }

  /**
   * Mimmics the Axios API using Fetch's Response object.
   * @param response - The response object.
   */
  async #wrap(response: Response): Promise<ApiResponse> {
    return {
      success: response.ok,
      status_code: response.status,
      data: JSON.parse(await response.text())
    };
  }

  /**
   * Makes calls to the playback tracking API.
   * @param url - The URL to call.
   * @param client - The client to use.
   * @param params - Call parameters.
   */
  async stats(url: string, client: { client_name: string; client_version: string }, params: { [key: string]: any }): Promise<Response> {
    const s_url = new URL(url);

    s_url.searchParams.set('ver', '2');
    s_url.searchParams.set('c', client.client_name.toLowerCase());
    s_url.searchParams.set('cbrver', client.client_version);
    s_url.searchParams.set('cver', client.client_version);

    for (const key of Object.keys(params)) {
      s_url.searchParams.set(key, params[key]);
    }

    const response = await this.session.http.fetch(s_url);

    return response;
  }

  /**
   * Executes an API call.
   * @param endpoint - The endpoint to call.
   * @param args - Call arguments
   */
  async execute<T extends InnertubeEndpoint>(endpoint: T, args: { [key: string]: any; parse: true; protobuf?: false; serialized_data?: any }): Promise<ParsedResponse<T>>;
  async execute<T extends InnertubeEndpoint>(endpoint: T, args?: { [key: string]: any; parse?: false; protobuf?: true; serialized_data?: any }): Promise<ApiResponse>;
  async execute<T extends InnertubeEndpoint>(endpoint: T, args?: { [key: string]: any; parse?: boolean; protobuf?: boolean; serialized_data?: any }): Promise<ParsedResponse<T> | ApiResponse> {
    let data;

    if (args && !args.protobuf) {
      data = { ...args };

      if (Reflect.has(data, 'browseId')) {
        if (this.#needsLogin(data.browseId) && !this.session.logged_in)
          throw new InnertubeError('You must be signed in to perform this operation.');
      }

      if (Reflect.has(data, 'override_endpoint'))
        delete data.override_endpoint;

      if (Reflect.has(data, 'parse'))
        delete data.parse;

      if (Reflect.has(data, 'request'))
        delete data.request;

      if (Reflect.has(data, 'clientActions'))
        delete data.clientActions;

      if (Reflect.has(data, 'settingItemIdForClient'))
        delete data.settingItemIdForClient;

      if (Reflect.has(data, 'action')) {
        data.actions = [ data.action ];
        delete data.action;
      }

      if (Reflect.has(data, 'boolValue')) {
        data.newValue = { boolValue: data.boolValue };
        delete data.boolValue;
      }

      if (Reflect.has(data, 'token')) {
        data.continuation = data.token;
        delete data.token;
      }

      if (data?.client === 'YTMUSIC') {
        data.isAudioOnly = true;
      }
    } else if (args) {
      data = args.serialized_data;
    }

    const target_endpoint = Reflect.has(args || {}, 'override_endpoint') ? args?.override_endpoint : endpoint;

    const response = await this.session.http.fetch(target_endpoint, {
      method: 'POST',
      body: args?.protobuf ? data : JSON.stringify((data || {})),
      headers: {
        'Content-Type': args?.protobuf ?
          'application/x-protobuf' :
          'application/json'
      }
    });

    if (args?.parse) {
      let parsed_response = Parser.parseResponse<ParsedResponse<T>>(await response.json());

      // Handle redirects
      if (this.#isBrowse(parsed_response) && parsed_response.on_response_received_actions?.first()?.type === 'navigateAction') {
        const navigate_action = parsed_response.on_response_received_actions.firstOfType(NavigateAction);
        if (navigate_action) {
          parsed_response = await navigate_action.endpoint.call(this, { parse: true });
        }
      }

      return parsed_response;
    }

    return this.#wrap(response);
  }

  #isBrowse(response: IParsedResponse): response is IBrowseResponse {
    return 'on_response_received_actions' in response;
  }

  #needsLogin(id: string) {
    return [
      'FElibrary',
      'FEhistory',
      'FEsubscriptions',
      'FEchannels',
      'FEplaylist_aggregation',
      'FEmusic_listening_review',
      'FEmusic_library_landing',
      'SPaccount_overview',
      'SPaccount_notifications',
      'SPaccount_privacy',
      'SPtime_watched'
    ].includes(id);
  }
}

LuanRT/YouTube.js/blob/main/src/core/OAuth2.ts:

import { OAuth2Error, Platform } from '../utils/Utils.js';
import { Log, Constants } from '../utils/index.js';
import type Session from './Session.js';

const TAG = 'OAuth2';

export type OAuth2ClientID = {
  client_id: string;
  client_secret: string;
};

export type OAuth2Tokens = {
  access_token: string;
  expiry_date: string;
  expires_in?: number;
  refresh_token: string;
  scope?: string;
  token_type?: string;
  client?: OAuth2ClientID;
};

export type DeviceAndUserCode = {
  device_code: string;
  expires_in: number;
  interval: number;
  user_code: string;
  verification_url: string;
  error_code?: string;
};

export type OAuth2AuthEventHandler = (data: { credentials: OAuth2Tokens; }) => void;
export type OAuth2AuthPendingEventHandler = (data: DeviceAndUserCode) => void;
export type OAuth2AuthErrorEventHandler = (err: OAuth2Error) => void;

export default class OAuth2 {
  #session: Session;

  YTTV_URL: URL;
  AUTH_SERVER_CODE_URL: URL;
  AUTH_SERVER_TOKEN_URL: URL;
  AUTH_SERVER_REVOKE_TOKEN_URL: URL;

  client_id: OAuth2ClientID | undefined;
  oauth2_tokens: OAuth2Tokens | undefined;

  constructor(session: Session) {
    this.#session = session;
    this.YTTV_URL = new URL('/tv', Constants.URLS.YT_BASE);
    this.AUTH_SERVER_CODE_URL = new URL('/o/oauth2/device/code', Constants.URLS.YT_BASE);
    this.AUTH_SERVER_TOKEN_URL = new URL('/o/oauth2/token', Constants.URLS.YT_BASE);
    this.AUTH_SERVER_REVOKE_TOKEN_URL = new URL('/o/oauth2/revoke', Constants.URLS.YT_BASE);
  }

  async init(tokens?: OAuth2Tokens): Promise<void> {
    if (tokens) {
      this.setTokens(tokens);

      if (this.shouldRefreshToken()) {
        await this.refreshAccessToken();
      }

      this.#session.emit('auth', { credentials: this.oauth2_tokens });

      return;
    }

    const loaded_from_cache = await this.#loadFromCache();

    if (loaded_from_cache) {
      Log.info(TAG, 'Loaded OAuth2 tokens from cache.', this.oauth2_tokens);
      return;
    }

    if (!this.client_id)
      this.client_id = await this.getClientID();

    // Initialize OAuth2 flow
    const device_and_user_code = await this.getDeviceAndUserCode();

    this.#session.emit('auth-pending', device_and_user_code);

    this.pollForAccessToken(device_and_user_code);
  }

  setTokens(tokens: OAuth2Tokens): void {
    const tokensMod = tokens;

    // Convert access token remaining lifetime to ISO string
    if (tokensMod.expires_in) {
      tokensMod.expiry_date = new Date(Date.now() + tokensMod.expires_in * 1000).toISOString();
      delete tokensMod.expires_in; // We don't need this anymore
    }

    if (!this.validateTokens(tokensMod))
      throw new OAuth2Error('Invalid tokens provided.');

    this.oauth2_tokens = tokensMod;

    if (tokensMod.client) {
      Log.info(TAG, 'Using provided client id and secret.');
      this.client_id = tokensMod.client;
    }
  }

  async cacheCredentials(): Promise<void> {
    const encoder = new TextEncoder();
    const data = encoder.encode(JSON.stringify(this.oauth2_tokens));
    await this.#session.cache?.set('youtubei_oauth_credentials', data.buffer);
  }

  async #loadFromCache(): Promise<boolean> {
    const data = await this.#session.cache?.get('youtubei_oauth_credentials');
    if (!data)
      return false;

    const decoder = new TextDecoder();
    const credentials = JSON.parse(decoder.decode(data));

    this.setTokens(credentials);

    this.#session.emit('auth', { credentials });

    return true;
  }

  async removeCache(): Promise<void> {
    await this.#session.cache?.remove('youtubei_oauth_credentials');
  }

  async pollForAccessToken(device_and_user_code: DeviceAndUserCode): Promise<void> {
    if (!this.client_id)
      throw new OAuth2Error('Client ID is missing.');

    const { device_code, interval } = device_and_user_code;
    const { client_id, client_secret } = this.client_id;

    const payload = {
      client_id,
      client_secret,
      code: device_code,
      grant_type: 'http://oauth.net/grant_type/device/1.0'
    };

    const connInterval = setInterval(async () => {
      const response = await this.#http.fetch_function(this.AUTH_SERVER_TOKEN_URL, {
        body: JSON.stringify(payload),
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        }
      });

      const response_data = await response.json();

      if (response_data.error) {
        switch (response_data.error) {
          case 'access_denied':
            this.#session.emit('auth-error', new OAuth2Error('Access was denied.', response_data));
            clearInterval(connInterval);
            break;
          case 'expired_token':
            this.#session.emit('auth-error', new OAuth2Error('The device code has expired.', response_data));
            clearInterval(connInterval);
            break;
          case 'authorization_pending':
          case 'slow_down':
            Log.info(TAG, 'Polling for access token...');
            break;
          default:
            this.#session.emit('auth-error', new OAuth2Error('Server returned an unexpected error.', response_data));
            clearInterval(connInterval);
            break;
        }
        return;
      }

      this.setTokens(response_data);

      this.#session.emit('auth', { credentials: this.oauth2_tokens });

      clearInterval(connInterval);
    }, interval * 1000);
  }

  async revokeCredentials(): Promise<Response | undefined> {
    if (!this.oauth2_tokens)
      throw new OAuth2Error('Access token not found');

    await this.removeCache();

    const url = this.AUTH_SERVER_REVOKE_TOKEN_URL;
    url.searchParams.set('token', this.oauth2_tokens.access_token);

    return this.#session.http.fetch_function(url, { method: 'POST' });
  }

  async refreshAccessToken(): Promise<void> {
    if (!this.client_id)
      this.client_id = await this.getClientID();

    if (!this.oauth2_tokens)
      throw new OAuth2Error('No tokens available to refresh.');

    const { client_id, client_secret } = this.client_id;
    const { refresh_token } = this.oauth2_tokens;

    const payload = {
      client_id,
      client_secret,
      refresh_token,
      grant_type: 'refresh_token'
    };

    const response = await this.#http.fetch_function(this.AUTH_SERVER_TOKEN_URL, {
      body: JSON.stringify(payload),
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      }
    });

    if (!response.ok)
      throw new OAuth2Error(`Failed to refresh access token: ${response.status}`);

    const response_data = await response.json();

    if (response_data.error_code)
      throw new OAuth2Error('Authorization server returned an error', response_data);

    this.oauth2_tokens.access_token = response_data.access_token;
    this.oauth2_tokens.expiry_date = new Date(Date.now() + response_data.expires_in * 1000).toISOString();

    this.#session.emit('update-credentials', { credentials: this.oauth2_tokens });
  }

  async getDeviceAndUserCode(): Promise<DeviceAndUserCode> {
    if (!this.client_id)
      throw new OAuth2Error('Client ID is missing.');

    const { client_id } = this.client_id;

    const payload = {
      client_id,
      scope: 'http://gdata.youtube.com https://www.googleapis.com/auth/youtube-paid-content',
      device_id: Platform.shim.uuidv4(),
      device_model: 'ytlr::'
    };

    const response = await this.#http.fetch_function(this.AUTH_SERVER_CODE_URL, {
      body: JSON.stringify(payload),
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      }
    });

    if (!response.ok)
      throw new OAuth2Error(`Failed to get device/user code: ${response.status}`);

    const response_data = await response.json();

    if (response_data.error_code)
      throw new OAuth2Error('Authorization server returned an error', response_data);

    return response_data;
  }

  async getClientID(): Promise<OAuth2ClientID> {
    const yttv_response = await this.#http.fetch_function(this.YTTV_URL, {
      headers: {
        'User-Agent': 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version',
        'Referer': 'https://www.youtube.com/tv',
        'Accept-Language': 'en-US'
      }
    });

    if (!yttv_response.ok)
      throw new OAuth2Error(`Failed to get client ID: ${yttv_response.status}`);

    const yttv_response_data = await yttv_response.text();

    let script_url_body: RegExpExecArray | null;

    if ((script_url_body = Constants.OAUTH.REGEX.TV_SCRIPT.exec(yttv_response_data)) !== null) {
      Log.info(TAG, `Got YouTubeTV script URL (${script_url_body[1]})`);

      const script_response = await this.#http.fetch(script_url_body[1], { baseURL: Constants.URLS.YT_BASE });

      if (!script_response.ok)
        throw new OAuth2Error(`TV script request failed with status code ${script_response.status}`);

      const script_response_data = await script_response.text();

      const client_identity = script_response_data
        .match(Constants.OAUTH.REGEX.CLIENT_IDENTITY);

      if (!client_identity || !client_identity.groups)
        throw new OAuth2Error('Could not obtain client ID.');

      const { client_id, client_secret } = client_identity.groups;

      Log.info(TAG, `Client identity retrieved (clientId=${client_id}, clientSecret=${client_secret}).`);

      return {
        client_id,
        client_secret
      };
    }

    throw new OAuth2Error('Could not obtain script URL.');
  }

  shouldRefreshToken(): boolean {
    if (!this.oauth2_tokens)
      return false;
    return Date.now() > new Date(this.oauth2_tokens.expiry_date).getTime();
  }

  validateTokens(tokens: OAuth2Tokens): boolean {
    const propertiesAreValid = (
      Boolean(tokens.access_token) &&
      Boolean(tokens.expiry_date) &&
      Boolean(tokens.refresh_token)
    );

    const typesAreValid = (
      typeof tokens.access_token === 'string' &&
      typeof tokens.expiry_date === 'string' &&
      typeof tokens.refresh_token === 'string'
    );

    return typesAreValid && propertiesAreValid;
  }

  get #http() {
    return this.#session.http;
  }
}

LuanRT/YouTube.js/blob/main/src/core/Player.ts:

import { Log, LZW, Constants } from '../utils/index.js';
import { Platform, getRandomUserAgent, getStringBetweenStrings, findFunction, PlayerError } from '../utils/Utils.js';
import type { ICache, FetchFunction } from '../types/index.js';

const TAG = 'Player';

/**
 * Represents YouTube's player script. This is required to decipher signatures.
 */
export default class Player {
  player_id: string;
  sts: number;
  nsig_sc?: string;
  sig_sc?: string;
  po_token?: string;

  constructor(player_id: string, signature_timestamp: number, sig_sc?: string, nsig_sc?: string) {
    this.player_id = player_id;
    this.sts = signature_timestamp;
    this.nsig_sc = nsig_sc;
    this.sig_sc = sig_sc;
  }

  static async create(cache: ICache | undefined, fetch: FetchFunction = Platform.shim.fetch, po_token?: string): Promise<Player> {
    const url = new URL('/iframe_api', Constants.URLS.YT_BASE);
    const res = await fetch(url);

    if (res.status !== 200)
      throw new PlayerError('Failed to request player id');

    const js = await res.text();

    const player_id = getStringBetweenStrings(js, 'player\\/', '\\/');

    Log.info(TAG, `Got player id (${player_id}). Checking for cached players..`);

    if (!player_id)
      throw new PlayerError('Failed to get player id');

    // We have the player id, now we can check if we have a cached player.
    if (cache) {
      const cached_player = await Player.fromCache(cache, player_id);
      if (cached_player) {
        Log.info(TAG, 'Found up-to-date player data in cache.');
        cached_player.po_token = po_token;
        return cached_player;
      }
    }

    const player_url = new URL(`/s/player/${player_id}/player_ias.vflset/en_US/base.js`, Constants.URLS.YT_BASE);

    Log.info(TAG, `Could not find any cached player. Will download a new player from ${player_url}.`);

    const player_res = await fetch(player_url, {
      headers: {
        'user-agent': getRandomUserAgent('desktop')
      }
    });

    if (!player_res.ok) {
      throw new PlayerError(`Failed to get player data: ${player_res.status}`);
    }

    const player_js = await player_res.text();

    const sig_timestamp = this.extractSigTimestamp(player_js);
    const sig_sc = this.extractSigSourceCode(player_js);
    const nsig_sc = this.extractNSigSourceCode(player_js);

    Log.info(TAG, `Got signature timestamp (${sig_timestamp}) and algorithms needed to decipher signatures.`);

    const player = await Player.fromSource(player_id, sig_timestamp, cache, sig_sc, nsig_sc);
    player.po_token = po_token;

    return player;
  }

  decipher(url?: string, signature_cipher?: string, cipher?: string, this_response_nsig_cache?: Map<string, string>): string {
    url = url || signature_cipher || cipher;

    if (!url)
      throw new PlayerError('No valid URL to decipher');

    const args = new URLSearchParams(url);
    const url_components = new URL(args.get('url') || url);

    if (this.sig_sc && (signature_cipher || cipher)) {
      const signature = Platform.shim.eval(this.sig_sc, {
        sig: args.get('s')
      });

      Log.info(TAG, `Transformed signature from ${args.get('s')} to ${signature}.`);

      if (typeof signature !== 'string')
        throw new PlayerError('Failed to decipher signature');

      const sp = args.get('sp');

      sp ?
        url_components.searchParams.set(sp, signature) :
        url_components.searchParams.set('signature', signature);
    }

    const n = url_components.searchParams.get('n');

    if (this.nsig_sc && n) {
      let nsig;

      if (this_response_nsig_cache && this_response_nsig_cache.has(n)) {
        nsig = this_response_nsig_cache.get(n) as string;
      } else {
        nsig = Platform.shim.eval(this.nsig_sc, {
          nsig: n
        });

        Log.info(TAG, `Transformed n signature from ${n} to ${nsig}.`);

        if (typeof nsig !== 'string')
          throw new PlayerError('Failed to decipher nsig');

        if (nsig.startsWith('enhanced_except_')) {
          Log.warn(TAG, 'Could not transform nsig, download may be throttled.');
        } else if (this_response_nsig_cache) {
          this_response_nsig_cache.set(n, nsig);
        }
      }

      url_components.searchParams.set('n', nsig);
    }

    // @NOTE: SABR requests should include the PoToken (not base64d, but as bytes!) in the payload.
    if (url_components.searchParams.get('sabr') !== '1' && this.po_token)
      url_components.searchParams.set('pot', this.po_token);

    const client = url_components.searchParams.get('c');

    switch (client) {
      case 'WEB':
        url_components.searchParams.set('cver', Constants.CLIENTS.WEB.VERSION);
        break;
      case 'WEB_REMIX':
        url_components.searchParams.set('cver', Constants.CLIENTS.YTMUSIC.VERSION);
        break;
      case 'WEB_KIDS':
        url_components.searchParams.set('cver', Constants.CLIENTS.WEB_KIDS.VERSION);
        break;
      case 'ANDROID':
        url_components.searchParams.set('cver', Constants.CLIENTS.ANDROID.VERSION);
        break;
      case 'ANDROID_MUSIC':
        url_components.searchParams.set('cver', Constants.CLIENTS.YTMUSIC_ANDROID.VERSION);
        break;
      case 'TVHTML5_SIMPLY_EMBEDDED_PLAYER':
        url_components.searchParams.set('cver', Constants.CLIENTS.TV_EMBEDDED.VERSION);
        break;
    }

    const result = url_components.toString();

    Log.info(TAG, `Deciphered URL: ${result}`);

    return url_components.toString();
  }

  static async fromCache(cache: ICache, player_id: string): Promise<Player | null> {
    const buffer = await cache.get(player_id);

    if (!buffer)
      return null;

    const view = new DataView(buffer);
    const version = view.getUint32(0, true);

    if (version !== Player.LIBRARY_VERSION)
      return null;

    const sig_timestamp = view.getUint32(4, true);

    const sig_len = view.getUint32(8, true);
    const sig_buf = buffer.slice(12, 12 + sig_len);
    const nsig_buf = buffer.slice(12 + sig_len);

    const sig_sc = LZW.decompress(new TextDecoder().decode(sig_buf));
    const nsig_sc = LZW.decompress(new TextDecoder().decode(nsig_buf));

    return new Player(player_id, sig_timestamp, sig_sc, nsig_sc);
  }

  static async fromSource(player_id: string, sig_timestamp: number, cache?: ICache, sig_sc?: string, nsig_sc?: string): Promise<Player> {
    const player = new Player(player_id, sig_timestamp, sig_sc, nsig_sc);
    await player.cache(cache);
    return player;
  }

  async cache(cache?: ICache): Promise<void> {
    if (!cache || !this.sig_sc || !this.nsig_sc)
      return;

    const encoder = new TextEncoder();

    const sig_buf = encoder.encode(LZW.compress(this.sig_sc));
    const nsig_buf = encoder.encode(LZW.compress(this.nsig_sc));

    const buffer = new ArrayBuffer(12 + sig_buf.byteLength + nsig_buf.byteLength);
    const view = new DataView(buffer);

    view.setUint32(0, Player.LIBRARY_VERSION, true);
    view.setUint32(4, this.sts, true);
    view.setUint32(8, sig_buf.byteLength, true);

    new Uint8Array(buffer).set(sig_buf, 12);
    new Uint8Array(buffer).set(nsig_buf, 12 + sig_buf.byteLength);

    await cache.set(this.player_id, new Uint8Array(buffer));
  }

  static extractSigTimestamp(data: string): number {
    return parseInt(getStringBetweenStrings(data, 'signatureTimestamp:', ',') || '0');
  }

  static extractSigSourceCode(data: string): string {
    const calls = getStringBetweenStrings(data, 'function(a){a=a.split("")', 'return a.join("")}');
    const obj_name = calls?.split(/\.|\[/)?.[0]?.replace(';', '')?.trim();
    const functions = getStringBetweenStrings(data, `var ${obj_name}={`, '};');

    if (!functions || !calls)
      Log.warn(TAG, 'Failed to extract signature decipher algorithm.');

    return `function descramble_sig(a) { a = a.split(""); let ${obj_name}={${functions}}${calls} return a.join("") } descramble_sig(sig);`;
  }

  static extractNSigSourceCode(data: string): string | undefined {
    const nsig_function = findFunction(data, { includes: 'enhanced_except' });
    if (nsig_function) {
      return `${nsig_function.result} ${nsig_function.name}(nsig);`;
    }
  }

  get url(): string {
    return new URL(`/s/player/${this.player_id}/player_ias.vflset/en_US/base.js`, Constants.URLS.YT_BASE).toString();
  }

  static get LIBRARY_VERSION(): number {
    return 11;
  }
}

LuanRT/YouTube.js/blob/main/src/core/Session.ts:

import OAuth2 from './OAuth2.js';
import { Log, EventEmitter, HTTPClient, LZW } from '../utils/index.js';
import * as Constants from '../utils/Constants.js';
import * as Proto from '../proto/index.js';
import Actions from './Actions.js';
import Player from './Player.js';

import {
  generateRandomString, getRandomUserAgent,
  InnertubeError, Platform, SessionError
} from '../utils/Utils.js';

import type { DeviceCategory } from '../utils/Utils.js';
import type { FetchFunction, ICache } from '../types/index.js';
import type { OAuth2Tokens, OAuth2AuthErrorEventHandler, OAuth2AuthPendingEventHandler, OAuth2AuthEventHandler } from './OAuth2.js';

export enum ClientType {
  WEB = 'WEB',
  KIDS = 'WEB_KIDS',
  MUSIC = 'WEB_REMIX',
  IOS = 'iOS',
  ANDROID = 'ANDROID',
  ANDROID_MUSIC = 'ANDROID_MUSIC',
  ANDROID_CREATOR = 'ANDROID_CREATOR',
  TV_EMBEDDED = 'TVHTML5_SIMPLY_EMBEDDED_PLAYER'
}

export type Context = {
  client: {
    hl: string;
    gl: string;
    remoteHost?: string;
    screenDensityFloat?: number;
    screenHeightPoints?: number;
    screenPixelDensity?: number;
    screenWidthPoints?: number;
    visitorData?: string;
    clientName: string;
    clientVersion: string;
    clientScreen?: string,
    androidSdkVersion?: number;
    osName: string;
    osVersion: string;
    platform: string;
    clientFormFactor: string;
    userInterfaceTheme?: string;
    timeZone: string;
    userAgent?: string;
    browserName?: string;
    browserVersion?: string;
    originalUrl?: string;
    deviceMake: string;
    deviceModel: string;
    utcOffsetMinutes: number;
    mainAppWebInfo?: {
      graftUrl: string;
      pwaInstallabilityStatus: string;
      webDisplayMode: string;
      isWebNativeShareAvailable: boolean;
    };
    memoryTotalKbytes?: string;
    configInfo?: {
      appInstallData: string;
    },
    kidsAppInfo?: {
      categorySettings: {
        enabledCategories: string[];
      };
      contentSettings: {
        corpusPreference: string;
        kidsNoSearchMode: string;
      };
    };
  };
  user: {
    enableSafetyMode: boolean;
    lockedSafetyMode: boolean;
    onBehalfOfUser?: string;
  };
  thirdParty?: {
    embedUrl: string;
  };
  request?: {
    useSsl: boolean;
    internalExperimentFlags: any[];
  };
}

type ContextData = {
  hl: string;
  gl: string;
  remote_host?: string;
  visitor_data: string;
  client_name: string;
  client_version: string;
  os_name: string;
  os_version: string;
  device_category: string;
  time_zone: string;
  enable_safety_mode: boolean;
  browser_name?: string;
  browser_version?: string;
  app_install_data?: string;
  device_make: string;
  device_model: string;
  on_behalf_of_user?: string;
}

export type SessionOptions = {
  /**
   * Language.
   */
  lang?: string;
  /**
   * Geolocation.
   */
  location?: string;
  /**
   * The account index to use. This is useful if you have multiple accounts logged in.
   *
   * **NOTE:** Only works if you are signed in with cookies.
   */
  account_index?: number;
  /**
   * Specify the Page ID of the YouTube profile/channel to use, if the logged-in account has multiple profiles.
   */
  on_behalf_of_user?: string;
  /**
   * Specifies whether to retrieve the JS player. Disabling this will make session creation faster.
   *
   * **NOTE:** Deciphering formats is not possible without the JS player.
   */
  retrieve_player?: boolean;
  /**
   * Specifies whether to enable safety mode. This will prevent the session from loading any potentially unsafe content.
   */
  enable_safety_mode?: boolean;
  /**
   * Specifies whether to generate the session data locally or retrieve it from YouTube.
   * This can be useful if you need more performance.
   *
   * **NOTE:** If you are using the cache option and a session has already been generated, this will be ignored.
   * If you want to force a new session to be generated, you must clear the cache or disable session caching.
   */
  generate_session_locally?: boolean;
  /**
   * Specifies whether the session data should be cached.
   */
  enable_session_cache?: boolean;
  /**
   * Platform to use for the session.
   */
  device_category?: DeviceCategory;
  /**
   * InnerTube client type.
   */
  client_type?: ClientType;
  /**
   * The time zone.
   */
  timezone?: string;
  /**
   * Used to cache algorithms, session data, and OAuth2 tokens.
   */
  cache?: ICache;
  /**
   * YouTube cookies.
   */
  cookie?: string;
  /**
   * Setting this to a valid and persistent visitor data string will allow YouTube to give this session tailored content even when not logged in.
   * A good way to get a valid one is by either grabbing it from a browser or calling InnerTube's `/visitor_id` endpoint.
   */
  visitor_data?: string;
  /**
   * Fetch function to use.
   */
  fetch?: FetchFunction;
  /**
   * Proof of Origin Token. This is an attestation token generated by BotGuard/DroidGuard. It is used to confirm that the request is coming from a genuine device.
   */
  po_token?: string;
}

export type SessionData = {
  context: Context;
  api_key: string;
  api_version: string;
}

export type SWSessionData = {
  context_data: ContextData;
  api_key: string;
  api_version: string;
}

export type SessionArgs = {
  lang: string;
  location: string;
  time_zone: string;
  device_category: DeviceCategory;
  client_name: ClientType;
  enable_safety_mode: boolean;
  visitor_data: string;
  on_behalf_of_user: string | undefined;
}

const TAG = 'Session';

/**
 * Represents an InnerTube session. This holds all the data needed to make requests to YouTube.
 */
export default class Session extends EventEmitter {
  context: Context;
  player?: Player;
  oauth: OAuth2;
  http: HTTPClient;
  logged_in: boolean;
  actions: Actions;
  cache?: ICache;
  key: string;
  api_version: string;
  account_index: number;
  po_token?: string;

  constructor(context: Context, api_key: string, api_version: string, account_index: number, player?: Player, cookie?: string, fetch?: FetchFunction, cache?: ICache, po_token?: string) {
    super();
    this.http = new HTTPClient(this, cookie, fetch);
    this.actions = new Actions(this);
    this.oauth = new OAuth2(this);
    this.logged_in = !!cookie;
    this.cache = cache;
    this.account_index = account_index;
    this.key = api_key;
    this.api_version = api_version;
    this.context = context;
    this.player = player;
    this.po_token = po_token;
  }

  on(type: 'auth', listener: OAuth2AuthEventHandler): void;
  on(type: 'auth-pending', listener: OAuth2AuthPendingEventHandler): void;
  on(type: 'auth-error', listener: OAuth2AuthErrorEventHandler): void;
  on(type: 'update-credentials', listener: OAuth2AuthEventHandler): void;

  on(type: string, listener: (...args: any[]) => void): void {
    super.on(type, listener);
  }

  once(type: 'auth', listener: OAuth2AuthEventHandler): void;
  once(type: 'auth-pending', listener: OAuth2AuthPendingEventHandler): void;
  once(type: 'auth-error', listener: OAuth2AuthErrorEventHandler): void;

  once(type: string, listener: (...args: any[]) => void): void {
    super.once(type, listener);
  }

  static async create(options: SessionOptions = {}) {
    const { context, api_key, api_version, account_index } = await Session.getSessionData(
      options.lang,
      options.location,
      options.account_index,
      options.visitor_data,
      options.enable_safety_mode,
      options.generate_session_locally,
      options.device_category,
      options.client_type,
      options.timezone,
      options.fetch,
      options.on_behalf_of_user,
      options.cache,
      options.enable_session_cache,
      options.po_token
    );

    return new Session(
      context, api_key, api_version, account_index,
      options.retrieve_player === false ? undefined : await Player.create(options.cache, options.fetch, options.po_token),
      options.cookie, options.fetch, options.cache, options.po_token
    );
  }

  /**
   * Retrieves session data from cache.
   * @param cache - A valid cache implementation.
   * @param session_args - User provided session arguments.
   */
  static async fromCache(cache: ICache, session_args: SessionArgs): Promise<SessionData | null> {
    const buffer = await cache.get('innertube_session_data');

    if (!buffer)
      return null;

    const data = new TextDecoder().decode(buffer.slice(4));

    try {
      const result = JSON.parse(LZW.decompress(data)) as SessionData;

      if (session_args.visitor_data) {
        result.context.client.visitorData = session_args.visitor_data;
      }

      if (session_args.lang)
        result.context.client.hl = session_args.lang;

      if (session_args.location)
        result.context.client.gl = session_args.location;

      if (session_args.on_behalf_of_user)
        result.context.user.onBehalfOfUser = session_args.on_behalf_of_user;

      result.context.client.timeZone = session_args.time_zone;
      result.context.client.platform = session_args.device_category.toUpperCase();
      result.context.client.clientName = session_args.client_name;
      result.context.user.enableSafetyMode = session_args.enable_safety_mode;

      return result;
    } catch (error) {
      Log.error(TAG, 'Failed to parse session data from cache.', error);
      return null;
    }
  }

  static async getSessionData(
    lang = '',
    location = '',
    account_index = 0,
    visitor_data = '',
    enable_safety_mode = false,
    generate_session_locally = false,
    device_category: DeviceCategory = 'desktop',
    client_name: ClientType = ClientType.WEB,
    tz: string = Intl.DateTimeFormat().resolvedOptions().timeZone,
    fetch: FetchFunction = Platform.shim.fetch,
    on_behalf_of_user?: string,
    cache?: ICache,
    enable_session_cache = true,
    po_token?: string
  ) {
    const session_args = { lang, location, time_zone: tz, device_category, client_name, enable_safety_mode, visitor_data, on_behalf_of_user, po_token };

    let session_data: SessionData | undefined;

    if (cache && enable_session_cache) {
      const cached_session_data = await this.fromCache(cache, session_args);
      if (cached_session_data) {
        Log.info(TAG, 'Found session data in cache.');
        session_data = cached_session_data;
      }
    }

    if (!session_data) {
      Log.info(TAG, 'Generating session data.');

      let api_key = Constants.CLIENTS.WEB.API_KEY;
      let api_version = Constants.CLIENTS.WEB.API_VERSION;

      let context_data: ContextData = {
        hl: lang || 'en',
        gl: location || 'US',
        remote_host: '',
        visitor_data: visitor_data || Proto.encodeVisitorData(generateRandomString(11), Math.floor(Date.now() / 1000)),
        client_name: client_name,
        client_version: Constants.CLIENTS.WEB.VERSION,
        device_category: device_category.toUpperCase(),
        os_name: 'Windows',
        os_version: '10.0',
        time_zone: tz,
        browser_name: 'Chrome',
        browser_version: '125.0.0.0',
        device_make: '',
        device_model: '',
        enable_safety_mode: enable_safety_mode
      };

      if (!generate_session_locally) {
        try {
          const sw_session_data = await this.#getSessionData(session_args, fetch);
          api_key = sw_session_data.api_key;
          api_version = sw_session_data.api_version;
          context_data = sw_session_data.context_data;
        } catch (error) {
          Log.error(TAG, 'Failed to retrieve session data from server. Session data generated locally will be used instead.', error);
        }
      }

      session_data = {
        api_key,
        api_version,
        context: this.#buildContext(context_data)
      };

      if (enable_session_cache)
        await this.#storeSession(session_data, cache);
    }

    Log.debug(TAG, 'Session data:', session_data);

    return { ...session_data, account_index };
  }

  static async #storeSession(session_data: SessionData, cache?: ICache) {
    if (!cache) return;

    Log.info(TAG, 'Compressing and caching session data.');

    const compressed_session_data = new TextEncoder().encode(LZW.compress(JSON.stringify(session_data)));

    const buffer = new ArrayBuffer(4 + compressed_session_data.byteLength);
    new DataView(buffer).setUint32(0, compressed_session_data.byteLength, true); // (Luan) XX: Leave this here for debugging purposes
    new Uint8Array(buffer).set(compressed_session_data, 4);

    await cache.set('innertube_session_data', new Uint8Array(buffer));
  }

  static async #getSessionData(options: SessionArgs, fetch: FetchFunction = Platform.shim.fetch): Promise<SWSessionData> {
    let visitor_id = generateRandomString(11);

    if (options.visitor_data)
      visitor_id = this.#getVisitorID(options.visitor_data);

    const url = new URL('/sw.js_data', Constants.URLS.YT_BASE);

    const res = await fetch(url, {
      headers: {
        'Accept-Language': options.lang || 'en-US',
        'User-Agent': getRandomUserAgent('desktop'),
        'Accept': '*/*',
        'Referer': `${Constants.URLS.YT_BASE}/sw.js`,
        'Cookie': `PREF=tz=${options.time_zone.replace('/', '.')};VISITOR_INFO1_LIVE=${visitor_id};`
      }
    });

    if (!res.ok)
      throw new SessionError(`Failed to retrieve session data: ${res.status}`);

    const text = await res.text();

    if (!text.startsWith(')]}\''))
      throw new SessionError('Invalid JSPB response');

    const data = JSON.parse(text.replace(/^\)\]\}'/, ''));

    const ytcfg = data[0][2];

    const api_version = Constants.CLIENTS.WEB.API_VERSION;

    const [ [ device_info ], api_key ] = ytcfg;

    const config_info = device_info[61];
    const app_install_data = config_info[config_info.length - 1];

    const context_info = {
      hl: options.lang || device_info[0],
      gl: options.location || device_info[2],
      remote_host: device_info[3],
      visitor_data: options.visitor_data || device_info[13],
      client_name: options.client_name,
      client_version: device_info[16],
      os_name: device_info[17],
      os_version: device_info[18],
      time_zone: device_info[79] || options.time_zone,
      device_category: options.device_category,
      browser_name: device_info[86],
      browser_version: device_info[87],
      device_make: device_info[11],
      device_model: device_info[12],
      app_install_data: app_install_data,
      enable_safety_mode: options.enable_safety_mode
    };

    return { context_data: context_info, api_key, api_version };
  }

  static #buildContext(args: ContextData) {
    const context: Context = {
      client: {
        hl: args.hl,
        gl: args.gl,
        remoteHost: args.remote_host,
        screenDensityFloat: 1,
        screenHeightPoints: 1440,
        screenPixelDensity: 1,
        screenWidthPoints: 2560,
        visitorData: args.visitor_data,
        clientName: args.client_name,
        clientVersion: args.client_version,
        osName: args.os_name,
        osVersion: args.os_version,
        platform: args.device_category.toUpperCase(),
        clientFormFactor: 'UNKNOWN_FORM_FACTOR',
        userInterfaceTheme: 'USER_INTERFACE_THEME_LIGHT',
        timeZone: args.time_zone,
        originalUrl: Constants.URLS.YT_BASE,
        deviceMake: args.device_make,
        deviceModel: args.device_model,
        browserName: args.browser_name,
        browserVersion: args.browser_version,
        utcOffsetMinutes: -Math.floor((new Date()).getTimezoneOffset()),
        memoryTotalKbytes: '8000000',
        mainAppWebInfo: {
          graftUrl: Constants.URLS.YT_BASE,
          pwaInstallabilityStatus: 'PWA_INSTALLABILITY_STATUS_UNKNOWN',
          webDisplayMode: 'WEB_DISPLAY_MODE_BROWSER',
          isWebNativeShareAvailable: true
        }
      },
      user: {
        enableSafetyMode: args.enable_safety_mode,
        lockedSafetyMode: false
      },
      request: {
        useSsl: true,
        internalExperimentFlags: []
      }
    };

    if (args.app_install_data)
      context.client.configInfo = { appInstallData: args.app_install_data };

    if (args.on_behalf_of_user)
      context.user.onBehalfOfUser = args.on_behalf_of_user;

    return context;
  }

  static #getVisitorID(visitor_data: string) {
    const decoded_visitor_data = Proto.decodeVisitorData(visitor_data);
    return decoded_visitor_data.id;
  }

  async signIn(credentials?: OAuth2Tokens): Promise<void> {
    return new Promise(async (resolve, reject) => {
      const error_handler: OAuth2AuthErrorEventHandler = (err) => reject(err);

      this.once('auth-error', error_handler);

      this.once('auth', () => {
        this.off('auth-error', error_handler);
        this.logged_in = true;
        resolve();
      });

      try {
        await this.oauth.init(credentials);
      } catch (err) {
        reject(err);
      }
    });
  }

  /**
   * Signs out of the current account and revokes the credentials.
   */
  async signOut(): Promise<Response | undefined> {
    if (!this.logged_in)
      throw new InnertubeError('You must be signed in to perform this operation.');

    const response = await this.oauth.revokeCredentials();
    this.logged_in = false;

    return response;
  }

  get client_version(): string {
    return this.context.client.clientVersion;
  }

  get client_name(): string {
    return this.context.client.clientName;
  }

  get lang(): string {
    return this.context.client.hl;
  }
}

LuanRT/YouTube.js/blob/main/src/core/clients/Kids.ts:

import { Parser } from '../../parser/index.js';
import { Channel, HomeFeed, Search, VideoInfo } from '../../parser/ytkids/index.js';
import { InnertubeError, generateRandomString } from '../../utils/Utils.js';
import KidsBlocklistPickerItem from '../../parser/classes/ytkids/KidsBlocklistPickerItem.js';

import {
  BrowseEndpoint, NextEndpoint,
  PlayerEndpoint, SearchEndpoint
} from '../endpoints/index.js';

import { BlocklistPickerEndpoint } from '../endpoints/kids/index.js';

import type { Session, ApiResponse } from '../index.js';

export default class Kids {
  #session: Session;

  constructor(session: Session) {
    this.#session = session;
  }

  /**
   * Searches the given query.
   * @param query - The query.
   */
  async search(query: string): Promise<Search> {
    const response = await this.#session.actions.execute(
      SearchEndpoint.PATH, SearchEndpoint.build({ client: 'YTKIDS', query })
    );
    return new Search(this.#session.actions, response);
  }

  /**
   * Retrieves video info.
   * @param video_id - The video id.
   */
  async getInfo(video_id: string): Promise<VideoInfo> {
    const player_payload = PlayerEndpoint.build({
      sts: this.#session.player?.sts,
      client: 'YTKIDS',
      video_id
    });

    const next_payload = NextEndpoint.build({
      video_id,
      client: 'YTKIDS'
    });

    const player_response = this.#session.actions.execute(PlayerEndpoint.PATH, player_payload);
    const next_response = this.#session.actions.execute(NextEndpoint.PATH, next_payload);
    const response = await Promise.all([ player_response, next_response ]);

    const cpn = generateRandomString(16);

    return new VideoInfo(response, this.#session.actions, cpn);
  }

  /**
   * Retrieves the contents of the given channel.
  * @param channel_id - The channel id.
   */
  async getChannel(channel_id: string): Promise<Channel> {
    const response = await this.#session.actions.execute(
      BrowseEndpoint.PATH, BrowseEndpoint.build({
        browse_id: channel_id,
        client: 'YTKIDS'
      })
    );
    return new Channel(this.#session.actions, response);
  }

  /**
   * Retrieves the home feed.
   */
  async getHomeFeed(): Promise<HomeFeed> {
    const response = await this.#session.actions.execute(
      BrowseEndpoint.PATH, BrowseEndpoint.build({
        browse_id: 'FEkids_home',
        client: 'YTKIDS'
      })
    );
    return new HomeFeed(this.#session.actions, response);
  }

  /**
   * Retrieves the list of supervised accounts that the signed-in user has
   * access to, and blocks the given channel for each of them.
   * @param channel_id - The channel id to block.
   * @returns A list of API responses.
   */
  async blockChannel(channel_id: string): Promise<ApiResponse[]> {
    if (!this.#session.logged_in)
      throw new InnertubeError('You must be signed in to perform this operation.');

    const blocklist_payload = BlocklistPickerEndpoint.build({ channel_id: channel_id });
    const response = await this.#session.actions.execute(BlocklistPickerEndpoint.PATH, blocklist_payload );
    const popup = response.data.command.confirmDialogEndpoint;
    const popup_fragment = { contents: popup.content, engagementPanels: [] };
    const kid_picker = Parser.parseResponse(popup_fragment);
    const kids = kid_picker.contents_memo?.getType(KidsBlocklistPickerItem);

    if (!kids)
      throw new InnertubeError('Could not find any kids profiles or supervised accounts.');

    // Iterate through the kids and block the channel if not already blocked.
    const responses: ApiResponse[] = [];

    for (const kid of kids) {
      if (!kid.block_button?.is_toggled) {
        kid.setActions(this.#session.actions);
        // Block channel and add to the response list.
        responses.push(await kid.blockChannel());
      }
    }

    return responses;
  }
}

LuanRT/YouTube.js/blob/main/src/core/clients/Music.ts:

import * as Proto from '../../proto/index.js';
import { InnertubeError, generateRandomString, throwIfMissing } from '../../utils/Utils.js';

import {
  Album, Artist, Explore,
  HomeFeed, Library, Playlist,
  Recap, Search, TrackInfo
} from '../../parser/ytmusic/index.js';

import AutomixPreviewVideo from '../../parser/classes/AutomixPreviewVideo.js';
import Message from '../../parser/classes/Message.js';
import MusicDescriptionShelf from '../../parser/classes/MusicDescriptionShelf.js';
import MusicQueue from '../../parser/classes/MusicQueue.js';
import MusicTwoRowItem from '../../parser/classes/MusicTwoRowItem.js';
import PlaylistPanel from '../../parser/classes/PlaylistPanel.js';
import SearchSuggestionsSection from '../../parser/classes/SearchSuggestionsSection.js';
import SectionList from '../../parser/classes/SectionList.js';
import Tab from '../../parser/classes/Tab.js';

import {
  BrowseEndpoint,
  NextEndpoint,
  PlayerEndpoint,
  SearchEndpoint
} from '../endpoints/index.js';

import { GetSearchSuggestionsEndpoint } from '../endpoints/music/index.js';

import type { ObservedArray } from '../../parser/helpers.js';
import type { MusicSearchFilters } from '../../types/index.js';
import type { Actions, Session } from '../index.js';

export default class Music {
  #session: Session;
  #actions: Actions;

  constructor(session: Session) {
    this.#session = session;
    this.#actions = session.actions;
  }

  /**
   * Retrieves track info. Passing a list item of type MusicTwoRowItem automatically starts a radio.
   * @param target - Video id or a list item.
   */
  getInfo(target: string | MusicTwoRowItem): Promise<TrackInfo> {
    if (target instanceof MusicTwoRowItem) {
      return this.#fetchInfoFromListItem(target);
    } else if (typeof target === 'string') {
      return this.#fetchInfoFromVideoId(target);
    }

    throw new InnertubeError('Invalid target, expected either a video id or a valid MusicTwoRowItem', target);
  }

  async #fetchInfoFromVideoId(video_id: string): Promise<TrackInfo> {
    const player_payload = PlayerEndpoint.build({
      video_id,
      sts: this.#session.player?.sts,
      client: 'YTMUSIC'
    });

    const next_payload = NextEndpoint.build({
      video_id,
      client: 'YTMUSIC'
    });

    const player_response = this.#actions.execute(PlayerEndpoint.PATH, player_payload);
    const next_response = this.#actions.execute(NextEndpoint.PATH, next_payload);
    const response = await Promise.all([ player_response, next_response ]);

    const cpn = generateRandomString(16);

    return new TrackInfo(response, this.#actions, cpn);
  }

  async #fetchInfoFromListItem(list_item: MusicTwoRowItem | undefined): Promise<TrackInfo> {
    if (!list_item)
      throw new InnertubeError('List item cannot be undefined');

    if (!list_item.endpoint)
      throw new Error('This item does not have an endpoint.');

    const player_response = list_item.endpoint.call(this.#actions, {
      client: 'YTMUSIC',
      playbackContext: {
        contentPlaybackContext: {
          ...{
            signatureTimestamp: this.#session.player?.sts
          }
        }
      }
    });

    const next_response = list_item.endpoint.call(this.#actions, {
      client: 'YTMUSIC',
      enablePersistentPlaylistPanel: true,
      override_endpoint: '/next'
    });

    const cpn = generateRandomString(16);

    const response = await Promise.all([ player_response, next_response ]);
    return new TrackInfo(response, this.#actions, cpn);
  }

  /**
   * Searches on YouTube Music.
   * @param query - Search query.
   * @param filters - Search filters.
   */
  async search(query: string, filters: MusicSearchFilters = {}): Promise<Search> {
    throwIfMissing({ query });

    const response = await this.#actions.execute(
      SearchEndpoint.PATH, SearchEndpoint.build({
        query, client: 'YTMUSIC',
        params: filters.type && filters.type !== 'all' ? Proto.encodeMusicSearchFilters(filters) : undefined
      })
    );

    return new Search(response, this.#actions, Reflect.has(filters, 'type') && filters.type !== 'all');
  }

  /**
   * Retrieves the home feed.
   */
  async getHomeFeed(): Promise<HomeFeed> {
    const response = await this.#actions.execute(
      BrowseEndpoint.PATH, BrowseEndpoint.build({
        browse_id: 'FEmusic_home',
        client: 'YTMUSIC'
      })
    );

    return new HomeFeed(response, this.#actions);
  }

  /**
   * Retrieves the Explore feed.
   */
  async getExplore(): Promise<Explore> {
    const response = await this.#actions.execute(
      BrowseEndpoint.PATH, BrowseEndpoint.build({
        client: 'YTMUSIC',
        browse_id: 'FEmusic_explore'
      })
    );

    return new Explore(response);
    // TODO: return new Explore(response, this.#actions);
  }

  /**
   * Retrieves the library.
   */
  async getLibrary(): Promise<Library> {
    const response = await this.#actions.execute(
      BrowseEndpoint.PATH, BrowseEndpoint.build({
        client: 'YTMUSIC',
        browse_id: 'FEmusic_library_landing'
      })
    );

    return new Library(response, this.#actions);
  }

  /**
   * Retrieves artist's info & content.
   * @param artist_id - The artist id.
   */
  async getArtist(artist_id: string): Promise<Artist> {
    throwIfMissing({ artist_id });

    if (!artist_id.startsWith('UC') && !artist_id.startsWith('FEmusic_library_privately_owned_artist'))
      throw new InnertubeError('Invalid artist id', artist_id);

    const response = await this.#actions.execute(
      BrowseEndpoint.PATH, BrowseEndpoint.build({
        client: 'YTMUSIC',
        browse_id: artist_id
      })
    );

    return new Artist(response, this.#actions);
  }

  /**
   * Retrieves album.
   * @param album_id - The album id.
   */
  async getAlbum(album_id: string): Promise<Album> {
    throwIfMissing({ album_id });

    if (!album_id.startsWith('MPR') && !album_id.startsWith('FEmusic_library_privately_owned_release'))
      throw new InnertubeError('Invalid album id', album_id);

    const response = await this.#actions.execute(
      BrowseEndpoint.PATH, BrowseEndpoint.build({
        client: 'YTMUSIC',
        browse_id: album_id
      })
    );

    return new Album(response);
  }

  /**
   * Retrieves playlist.
   * @param playlist_id - The playlist id.
   */
  async getPlaylist(playlist_id: string): Promise<Playlist> {
    throwIfMissing({ playlist_id });

    if (!playlist_id.startsWith('VL')) {
      playlist_id = `VL${playlist_id}`;
    }

    const response = await this.#actions.execute(
      BrowseEndpoint.PATH, BrowseEndpoint.build({
        client: 'YTMUSIC',
        browse_id: playlist_id
      })
    );

    return new Playlist(response, this.#actions);
  }

  /**
   * Retrieves up next.
   * @param video_id - The video id.
   * @param automix - Whether to enable automix.
   */
  async getUpNext(video_id: string, automix = true): Promise<PlaylistPanel> {
    throwIfMissing({ video_id });

    const response = await this.#actions.execute(
      NextEndpoint.PATH, { ...NextEndpoint.build({ video_id, client: 'YTMUSIC' }), parse: true }
    );

    const tabs = response.contents_memo?.getType(Tab);

    const tab = tabs?.first();

    if (!tab)
      throw new InnertubeError('Could not find target tab.');

    const music_queue = tab.content?.as(MusicQueue);

    if (!music_queue || !music_queue.content)
      throw new InnertubeError('Music queue was empty, the given id is probably invalid.', music_queue);

    const playlist_panel = music_queue.content.as(PlaylistPanel);

    if (!playlist_panel.playlist_id && automix) {
      const automix_preview_video = playlist_panel.contents.firstOfType(AutomixPreviewVideo);

      if (!automix_preview_video)
        throw new InnertubeError('Automix item not found');

      const page = await automix_preview_video.playlist_video?.endpoint.call(this.#actions, {
        videoId: video_id,
        client: 'YTMUSIC',
        parse: true
      });

      if (!page || !page.contents_memo)
        throw new InnertubeError('Could not fetch automix');

      return page.contents_memo.getType(PlaylistPanel).first();
    }

    return playlist_panel;
  }

  /**
   * Retrieves related content.
   * @param video_id - The video id.
   */
  async getRelated(video_id: string): Promise<SectionList | Message> {
    throwIfMissing({ video_id });

    const response = await this.#actions.execute(
      NextEndpoint.PATH, { ...NextEndpoint.build({ video_id, client: 'YTMUSIC' }), parse: true }
    );

    const tabs = response.contents_memo?.getType(Tab);

    const tab = tabs?.matchCondition((tab) => tab.endpoint.payload.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType === 'MUSIC_PAGE_TYPE_TRACK_RELATED');

    if (!tab)
      throw new InnertubeError('Could not find target tab.');

    const page = await tab.endpoint.call(this.#actions, { client: 'YTMUSIC', parse: true });

    if (!page.contents)
      throw new InnertubeError('Unexpected response', page);

    const contents = page.contents.item().as(SectionList, Message);

    return contents;
  }

  /**
   * Retrieves song lyrics.
   * @param video_id - The video id.
   */
  async getLyrics(video_id: string): Promise<MusicDescriptionShelf | undefined> {
    throwIfMissing({ video_id });

    const response = await this.#actions.execute(
      NextEndpoint.PATH, { ...NextEndpoint.build({ video_id, client: 'YTMUSIC' }), parse: true }
    );

    const tabs = response.contents_memo?.getType(Tab);

    const tab = tabs?.matchCondition((tab) => tab.endpoint.payload.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType === 'MUSIC_PAGE_TYPE_TRACK_LYRICS');

    if (!tab)
      throw new InnertubeError('Could not find target tab.');

    const page = await tab.endpoint.call(this.#actions, { client: 'YTMUSIC', parse: true });

    if (!page.contents)
      throw new InnertubeError('Unexpected response', page);

    if (page.contents.item().type === 'Message')
      throw new InnertubeError(page.contents.item().as(Message).text.toString(), video_id);

    const section_list = page.contents.item().as(SectionList).contents;

    return section_list.firstOfType(MusicDescriptionShelf);
  }

  /**
   * Retrieves recap.
   */
  async getRecap(): Promise<Recap> {
    const response = await this.#actions.execute(
      BrowseEndpoint.PATH, BrowseEndpoint.build({
        client: 'YTMUSIC_ANDROID',
        browse_id: 'FEmusic_listening_review'
      })
    );

    return new Recap(response, this.#actions);
  }

  /**
   * Retrieves search suggestions for the given query.
   * @param query - The query.
   */
  async getSearchSuggestions(query: string): Promise<ObservedArray<SearchSuggestionsSection>> {
    const response = await this.#actions.execute(
      GetSearchSuggestionsEndpoint.PATH,
      { ...GetSearchSuggestionsEndpoint.build({ input: query }), parse: true }
    );

    if (!response.contents_memo)
      return [] as unknown as ObservedArray<SearchSuggestionsSection>;

    const search_suggestions_sections = response.contents_memo.getType(SearchSuggestionsSection);

    return search_suggestions_sections;
  }
}

LuanRT/YouTube.js/blob/main/src/core/clients/Studio.ts:

import * as Proto from '../../proto/index.js';
import { Constants } from '../../utils/index.js';
import { InnertubeError, MissingParamError, Platform } from '../../utils/Utils.js';
import { CreateVideoEndpoint } from '../endpoints/upload/index.js';

import type { UpdateVideoMetadataOptions, UploadedVideoMetadataOptions } from '../../types/Clients.js';
import type { ApiResponse, Session } from '../index.js';

interface UploadResult {
  status: string;
  scottyResourceId: string;
}

interface InitialUploadData {
  frontend_upload_id: string;
  upload_id: string;
  upload_url: string;
  scotty_resource_id: string;
  chunk_granularity: string;
}

export default class Studio {
  #session: Session;

  constructor(session: Session) {
    this.#session = session;
  }

  /**
   * Uploads a custom thumbnail and sets it for a video.
   * @example
   * ```ts
   * const buffer = fs.readFileSync('./my_awesome_thumbnail.jpg');
   * const response = await yt.studio.setThumbnail(video_id, buffer);
   * ```
   */
  async setThumbnail(video_id: string, buffer: Uint8Array): Promise<ApiResponse> {
    if (!this.#session.logged_in)
      throw new InnertubeError('You must be signed in to perform this operation.');

    if (!video_id || !buffer)
      throw new MissingParamError('One or more parameters are missing.');

    const payload = Proto.encodeCustomThumbnailPayload(video_id, buffer);

    const response = await this.#session.actions.execute('/video_manager/metadata_update', {
      protobuf: true,
      serialized_data: payload
    });

    return response;
  }

  /**
   * Updates a given video's metadata.
   * @example
   * ```ts
   * const response = await yt.studio.updateVideoMetadata('videoid', {
   *   tags: [ 'astronomy', 'NASA', 'APOD' ],
   *   title: 'Artemis Mission',
   *   description: 'A nicely written description...',
   *   category: 27,
   *   license: 'creative_commons'
   *   // ...
   * });
   * ```
   */
  async updateVideoMetadata(video_id: string, metadata: UpdateVideoMetadataOptions): Promise<ApiResponse> {
    if (!this.#session.logged_in)
      throw new InnertubeError('You must be signed in to perform this operation.');

    const payload = Proto.encodeVideoMetadataPayload(video_id, metadata);

    const response = await this.#session.actions.execute('/video_manager/metadata_update', {
      protobuf: true,
      serialized_data: payload
    });

    return response;
  }

  /**
   * Uploads a video to YouTube.
   * @example
   * ```ts
   * const file = fs.readFileSync('./my_awesome_video.mp4');
   * const response = await yt.studio.upload(file.buffer, { title: 'Wow!' });
   * ```
   */
  async upload(file: BodyInit, metadata: UploadedVideoMetadataOptions = {}): Promise<ApiResponse> {
    if (!this.#session.logged_in)
      throw new InnertubeError('You must be signed in to perform this operation.');

    const initial_data = await this.#getInitialUploadData();
    const upload_result = await this.#uploadVideo(initial_data.upload_url, file);

    if (upload_result.status !== 'STATUS_SUCCESS')
      throw new InnertubeError('Could not process video.');

    const response = await this.#setVideoMetadata(initial_data, upload_result, metadata);

    return response;
  }

  async #getInitialUploadData(): Promise<InitialUploadData> {
    const frontend_upload_id = `innertube_android:${Platform.shim.uuidv4()}:0:v=3,api=1,cf=3`;

    const payload = {
      frontendUploadId: frontend_upload_id,
      deviceDisplayName: 'Pixel 6 Pro',
      fileId: `goog-edited-video://generated?videoFileUri=content://media/external/video/media/${Platform.shim.uuidv4()}`,
      mp4MoovAtomRelocationStatus: 'UNSUPPORTED',
      transcodeResult: 'DISABLED',
      connectionType: 'WIFI'
    };

    const response = await this.#session.http.fetch('/upload/youtubei', {
      baseURL: Constants.URLS.YT_UPLOAD,
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'x-goog-upload-command': 'start',
        'x-goog-upload-protocol': 'resumable'
      },
      body: JSON.stringify(payload)
    });

    if (!response.ok)
      throw new InnertubeError('Could not get initial upload data');

    return {
      frontend_upload_id,
      upload_id: response.headers.get('x-guploader-uploadid') as string,
      upload_url: response.headers.get('x-goog-upload-url') as string,
      scotty_resource_id: response.headers.get('x-goog-upload-header-scotty-resource-id') as string,
      chunk_granularity: response.headers.get('x-goog-upload-chunk-granularity') as string
    };
  }

  async #uploadVideo(upload_url: string, file: BodyInit): Promise<UploadResult> {
    const response = await this.#session.http.fetch_function(upload_url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'x-goog-upload-command': 'upload, finalize',
        'x-goog-upload-file-name': `file-${Date.now()}`,
        'x-goog-upload-offset': '0'
      },
      body: file
    });

    if (!response.ok)
      throw new InnertubeError('Could not upload video');

    const data = await response.json();

    return data;
  }

  async #setVideoMetadata(initial_data: InitialUploadData, upload_result: UploadResult, metadata: UploadedVideoMetadataOptions) {
    const response = await this.#session.actions.execute(
      CreateVideoEndpoint.PATH, CreateVideoEndpoint.build({
        resource_id: {
          scotty_resource_id: {
            id: upload_result.scottyResourceId
          }
        },
        frontend_upload_id: initial_data.frontend_upload_id,
        initial_metadata: {
          title: {
            new_title: metadata.title || new Date().toDateString()
          },
          description: {
            new_description: metadata.description || '',
            should_segment: true
          },
          privacy: {
            new_privacy: metadata.privacy || 'PRIVATE'
          },
          draft_state: {
            is_draft: metadata.is_draft
          }
        },
        client: 'ANDROID'
      })
    );

    return response;
  }
}

LuanRT/YouTube.js/blob/main/src/core/clients/index.ts:

export { default as Kids } from './Kids.js';
export { default as Music } from './Music.js';
export { default as Studio } from './Studio.js';

LuanRT/YouTube.js/blob/main/src/core/endpoints/BrowseEndpoint.ts:

import type { IBrowseRequest, BrowseEndpointOptions } from '../../types/index.js';

export const PATH = '/browse';

/**
 * Builds a `/browse` request payload.
 * @param opts - The options to use.
 * @returns The payload.
 */
export function build(opts: BrowseEndpointOptions): IBrowseRequest {
  return {
    ...{
      browseId: opts.browse_id,
      params: opts.params,
      continuation: opts.continuation,
      client: opts.client
    }
  };
}

LuanRT/YouTube.js/blob/main/src/core/endpoints/GetNotificationMenuEndpoint.ts:

import type { IGetNotificationMenuRequest, GetNotificationMenuEndpointOptions } from '../../types/index.js';

export const PATH = '/notification/get_notification_menu';

/**
 * Builds a `/get_notification_menu` request payload.
 * @param opts - The options to use.
 * @returns The payload.
 */
export function build(opts: GetNotificationMenuEndpointOptions): IGetNotificationMenuRequest {
  return {
    ...{
      notificationsMenuRequestType: opts.notifications_menu_request_type
    }
  };
}

LuanRT/YouTube.js/blob/main/src/core/endpoints/GuideEndpoint.ts:

export const PATH = '/guide';

LuanRT/YouTube.js/blob/main/src/core/endpoints/NextEndpoint.ts:

import type { INextRequest, NextEndpointOptions } from '../../types/index.js';

export const PATH = '/next';

/**
 * Builds a `/next` request payload.
 * @param opts - The options to use.
 * @returns The payload.
 */
export function build(opts: NextEndpointOptions): INextRequest {
  return {
    ...{
      videoId: opts.video_id,
      playlistId: opts.playlist_id,
      params: opts.params,
      playlistIndex: opts.playlist_index,
      client: opts.client,
      continuation: opts.continuation
    }
  };
}

LuanRT/YouTube.js/blob/main/src/core/endpoints/PlayerEndpoint.ts:

import type { IPlayerRequest, PlayerEndpointOptions } from '../../types/index.js';

export const PATH = '/player';

/**
 * Builds a `/player` request payload.
 * @param opts - The options to use.
 * @returns The payload.
 */
export function build(opts: PlayerEndpointOptions): IPlayerRequest {
  const payload: IPlayerRequest = {
    playbackContext: {
      contentPlaybackContext: {
        vis: 0,
        splay: false,
        referer: opts.playlist_id ?
          `https://www.youtube.com/watch?v=${opts.video_id}&list=${opts.playlist_id}` :
          `https://www.youtube.com/watch?v=${opts.video_id}`,
        currentUrl: opts.playlist_id ?
          `/watch?v=${opts.video_id}&list=${opts.playlist_id}` :
          `/watch?v=${opts.video_id}`,
        autonavState: 'STATE_ON',
        autoCaptionsDefaultOn: false,
        html5Preference: 'HTML5_PREF_WANTS',
        lactMilliseconds: '-1',
        ...{
          signatureTimestamp: opts.sts
        }
      }
    },
    attestationRequest: {
      omitBotguardData: true
    },
    racyCheckOk: true,
    contentCheckOk: true,
    videoId: opts.video_id
  };

  if (opts.client)
    payload.client = opts.client;

  if (opts.playlist_id)
    payload.playlistId = opts.playlist_id;

  if (opts.params)
    payload.params = opts.params;

  if (opts.po_token)
    payload.serviceIntegrityDimensions = {
      poToken: opts.po_token
    };

  return payload;
}

LuanRT/YouTube.js/blob/main/src/core/endpoints/ResolveURLEndpoint.ts:

import type { IResolveURLRequest, ResolveURLEndpointOptions } from '../../types/index.js';

export const PATH = '/navigation/resolve_url';

/**
 * Builds a `/resolve_url` request payload.
 * @param opts - The options to use.
 * @returns The payload.
 */
export function build(opts: ResolveURLEndpointOptions): IResolveURLRequest {
  return {
    ...{
      url: opts.url
    }
  };
}

LuanRT/YouTube.js/blob/main/src/core/endpoints/SearchEndpoint.ts:

import type { ISearchRequest, SearchEndpointOptions } from '../../types/index.js';

export const PATH = '/search';

/**
 * Builds a `/search` request payload.
 * @param opts - The options to use.
 * @returns The payload.
 */
export function build(opts: SearchEndpointOptions): ISearchRequest {
  return {
    ...{
      query: opts.query,
      params: opts.params,
      continuation: opts.continuation,
      client: opts.client
    }
  };
}

LuanRT/YouTube.js/blob/main/src/core/endpoints/account/AccountListEndpoint.ts:

import type { IAccountListRequest } from '../../../types/index.js';

export const PATH = '/account/accounts_list';

/**
 * Builds a `/account/accounts_list` request payload.
 * @returns The payload.
 */
export function build(): IAccountListRequest {
  return {
    client: 'ANDROID'
  };
}

LuanRT/YouTube.js/blob/main/src/core/endpoints/account/index.ts:

export * as AccountListEndpoint from './AccountListEndpoint.js';

LuanRT/YouTube.js/blob/main/src/core/endpoints/browse/EditPlaylistEndpoint.ts:

import type { IEditPlaylistRequest, EditPlaylistEndpointOptions } from '../../../types/index.js';

export const PATH = '/browse/edit_playlist';

/**
 * Builds a `/browse/edit_playlist` request payload.
 * @param opts - The options to use.
 * @returns The payload.
 */
export function build(opts: EditPlaylistEndpointOptions): IEditPlaylistRequest {
  return {
    playlistId: opts.playlist_id,
    actions: opts.actions.map((action) => ({
      action: action.action,
      ...{
        addedVideoId: action.added_video_id,
        setVideoId: action.set_video_id,
        movedSetVideoIdPredecessor: action.moved_set_video_id_predecessor,
        playlistDescription: action.playlist_description,
        playlistName: action.playlist_name
      }
    }))
  };
}

LuanRT/YouTube.js/blob/main/src/core/endpoints/browse/index.ts:

export * as EditPlaylistEndpoint from './EditPlaylistEndpoint.js';

LuanRT/YouTube.js/blob/main/src/core/endpoints/channel/EditDescriptionEndpoint.ts:

import type { IChannelEditDescriptionRequest, ChannelEditDescriptionEndpointOptions } from '../../../types/Endpoints.js';

export const PATH = '/channel/edit_description';

/**
 * Builds a `/channel/edit_description` request payload.
 * @param options - The options to use.
 * @returns The payload.
 */
export function build(options: ChannelEditDescriptionEndpointOptions): IChannelEditDescriptionRequest {
  return {
    givenDescription: options.given_description,
    client: 'ANDROID'
  };
}

LuanRT/YouTube.js/blob/main/src/core/endpoints/channel/EditNameEndpoint.ts:

import type { IChannelEditNameRequest, ChannelEditNameEndpointOptions } from '../../../types/index.js';

export const PATH = '/channel/edit_name';

/**
 * Builds a `/channel/edit_name` request payload.
 * @param options - The options to use.
 * @returns The payload.
 */
export function build(options: ChannelEditNameEndpointOptions): IChannelEditNameRequest {
  return {
    givenName: options.given_name,
    client: 'ANDROID'
  };
}

LuanRT/YouTube.js/blob/main/src/core/endpoints/channel/index.ts:

export * as EditNameEndpoint from './EditNameEndpoint.js';
export * as EditDescriptionEndpoint from './EditDescriptionEndpoint.js';

LuanRT/YouTube.js/blob/main/src/core/endpoints/comment/CreateCommentEndpoint.ts:

import type { ICreateCommentRequest, CreateCommentEndpointOptions } from '../../../types/index.js';

export const PATH = '/comment/create_comment';

/**
 * Builds a `/comment/create_comment` request payload.
 * @param options - The options to use.
 * @returns The payload.
 */
export function build(options: CreateCommentEndpointOptions): ICreateCommentRequest {
  return {
    commentText: options.comment_text,
    createCommentParams: options.create_comment_params,
    ...{
      client: options.client
    }
  };
}

LuanRT/YouTube.js/blob/main/src/core/endpoints/comment/PerformCommentActionEndpoint.ts:

import type { IPerformCommentActionRequest, PerformCommentActionEndpointOptions } from '../../../types/index.js';

export const PATH = '/comment/perform_comment_action';

/**
 * Builds a `/comment/perform_comment_action` request payload.
 * @param options - The options to use.
 * @returns The payload.
 */
export function build(options: PerformCommentActionEndpointOptions): IPerformCommentActionRequest {
  return {
    actions: options.actions,
    ...{
      client: options.client
    }
  };
}

LuanRT/YouTube.js/blob/main/src/core/endpoints/comment/index.ts:

export * as PerformCommentActionEndpoint from './PerformCommentActionEndpoint.js';
export * as CreateCommentEndpoint from './CreateCommentEndpoint.js';

LuanRT/YouTube.js/blob/main/src/core/endpoints/index.ts:

export * as BrowseEndpoint from './BrowseEndpoint.js';
export * as GetNotificationMenuEndpoint from './GetNotificationMenuEndpoint.js';
export * as GuideEndpoint from './GuideEndpoint.js';
export * as NextEndpoint from './NextEndpoint.js';
export * as PlayerEndpoint from './PlayerEndpoint.js';
export * as ResolveURLEndpoint from './ResolveURLEndpoint.js';
export * as SearchEndpoint from './SearchEndpoint.js';

export * as Account from './account/index.js';
export * as Browse from './browse/index.js';
export * as Channel from './channel/index.js';
export * as Comment from './comment/index.js';
export * as Like from './like/index.js';
export * as Music from './music/index.js';
export * as Notification from './notification/index.js';
export * as Playlist from './playlist/index.js';
export * as Subscription from './subscription/index.js';
export * as Reel from './reel/index.js';
export * as Upload from './upload/index.js';
export * as Kids from './kids/index.js';

LuanRT/YouTube.js/blob/main/src/core/endpoints/kids/BlocklistPickerEndpoint.ts:

import type { IBlocklistPickerRequest, BlocklistPickerRequestEndpointOptions } from '../../../types/index.js';

export const PATH = '/kids/get_kids_blocklist_picker';

/**
 * Builds a `/kids/get_kids_blocklist_picker` request payload.
 * @param options - The options to use.
 * @returns The payload.
 */
export function build(options: BlocklistPickerRequestEndpointOptions): IBlocklistPickerRequest {
  return { blockedForKidsContent: { external_channel_id: options.channel_id } };
}

LuanRT/YouTube.js/blob/main/src/core/endpoints/kids/index.ts:

export * as BlocklistPickerEndpoint from './BlocklistPickerEndpoint.js';

LuanRT/YouTube.js/blob/main/src/core/endpoints/like/DislikeEndpoint.ts:

import type { IDislikeRequest, DislikeEndpointOptions } from '../../../types/index.js';

export const PATH = '/like/dislike';

/**
 * Builds a `/like/dislike` endpoint payload.
 * @param options - The options to use.
 * @returns The payload.
 */
export function build(options: DislikeEndpointOptions): IDislikeRequest {
  return {
    target: {
      videoId: options.target.video_id
    },
    ...{
      client: options.client
    }
  };
}

LuanRT/YouTube.js/blob/main/src/core/endpoints/like/LikeEndpoint.ts:

import type { ILikeRequest, LikeEndpointOptions } from '../../../types/index.js';

export const PATH = '/like/like';

/**
 * Builds a `/like/like` endpoint payload.
 * @param options - The options to use.
 * @returns The payload.
 */
export function build(options: LikeEndpointOptions): ILikeRequest {
  return {
    target: {
      videoId: options.target.video_id
    },
    ...{
      client: options.client
    }
  };
}

LuanRT/YouTube.js/blob/main/src/core/endpoints/like/RemoveLikeEndpoint.ts:

import type { IRemoveLikeRequest, RemoveLikeEndpointOptions } from '../../../types/index.js';

export const PATH = '/like/removelike';

/**
 * Builds a `/like/removelike` endpoint payload.
 * @param options - The options to use.
 * @returns The payload.
 */
export function build(options: RemoveLikeEndpointOptions): IRemoveLikeRequest {
  return {
    target: {
      videoId: options.target.video_id
    },
    ...{
      client: options.client
    }
  };
}

LuanRT/YouTube.js/blob/main/src/core/endpoints/like/index.ts:

export * as LikeEndpoint from './LikeEndpoint.js';
export * as DislikeEndpoint from './DislikeEndpoint.js';
export * as RemoveLikeEndpoint from './RemoveLikeEndpoint.js';

LuanRT/YouTube.js/blob/main/src/core/endpoints/music/GetSearchSuggestionsEndpoint.ts:

import type { IMusicGetSearchSuggestionsRequest, MusicGetSearchSuggestionsEndpointOptions } from '../../../types/index.js';


export const PATH = '/music/get_search_suggestions';

/**
 * Builds a `/music/get_search_suggestions` request payload.
 * @param opts - The options to use.
 * @returns The payload.
 */
export function build(opts: MusicGetSearchSuggestionsEndpointOptions): IMusicGetSearchSuggestionsRequest {
  return {
    input: opts.input,
    client: 'YTMUSIC'
  };
}

LuanRT/YouTube.js/blob/main/src/core/endpoints/music/index.ts:

export * as GetSearchSuggestionsEndpoint from './GetSearchSuggestionsEndpoint.js';

LuanRT/YouTube.js/blob/main/src/core/endpoints/notification/GetUnseenCountEndpoint.ts:

export const PATH = '/notification/get_unseen_count';

LuanRT/YouTube.js/blob/main/src/core/endpoints/notification/ModifyChannelPreferenceEndpoint.ts:

import type { IModifyChannelPreferenceRequest, ModifyChannelPreferenceEndpointOptions } from '../../../types/index.js';

export const PATH = '/notification/modify_channel_preference';

/**
 * Builds a `/notification/modify_channel_preference` request payload.
 * @param options - The options to use.
 * @returns The payload.
 */
export function build(options: ModifyChannelPreferenceEndpointOptions): IModifyChannelPreferenceRequest {
  return {
    params: options.params,
    ...{
      client: options.client
    }
  };
}

LuanRT/YouTube.js/blob/main/src/core/endpoints/notification/index.ts:

export * as GetUnseenCountEndpoint from './GetUnseenCountEndpoint.js';
export * as ModifyChannelPreferenceEndpoint from './ModifyChannelPreferenceEndpoint.js';

LuanRT/YouTube.js/blob/main/src/core/endpoints/playlist/CreateEndpoint.ts:

import type { ICreatePlaylistRequest, CreatePlaylistEndpointOptions } from '../../../types/index.js';

export const PATH = '/playlist/create';

/**
 * Builds a `/playlist/create` request payload.
 * @param opts - The options to use.
 * @returns The payload.
 */
export function build(opts: CreatePlaylistEndpointOptions): ICreatePlaylistRequest {
  return {
    title: opts.title,
    ids: opts.ids
  };
}

LuanRT/YouTube.js/blob/main/src/core/endpoints/playlist/DeleteEndpoint.ts:

import type { IDeletePlaylistRequest, DeletePlaylistEndpointOptions } from '../../../types/index.js';

export const PATH = '/playlist/delete';

/**
 * Builds a `/playlist/delete` request payload.
 * @param opts - The options to use.
 * @returns The payload.
 */
export function build(opts: DeletePlaylistEndpointOptions): IDeletePlaylistRequest {
  return {
    playlistId: opts.playlist_id
  };
}

LuanRT/YouTube.js/blob/main/src/core/endpoints/playlist/index.ts:

export * as CreateEndpoint from './CreateEndpoint.js';
export * as DeleteEndpoint from './DeleteEndpoint.js';

LuanRT/YouTube.js/blob/main/src/core/endpoints/reel/ReelItemWatchEndpoint.ts:

import type { IReelItemWatchRequest, ReelItemWatchEndpointOptions } from '../../../types/index.js';

export const PATH = '/reel/reel_item_watch';

/**
 * Builds a `/reel/reel_watch_sequence` request payload.
 * @param opts - The options to use.
 * @returns The payload.
 */
export function build(opts: ReelItemWatchEndpointOptions): IReelItemWatchRequest {
  return {
    disablePlayerResponse: false,
    playerRequest: {
      videoId: opts.video_id,
      params: opts.params ?? 'CAUwAg%3D%3D'
    },
    params: opts.params ?? 'CAUwAg%3D%3D',
    client: opts.client
  };
}

LuanRT/YouTube.js/blob/main/src/core/endpoints/reel/ReelWatchSequenceEndpoint.ts:

import type { IReelWatchSequenceRequest, ReelWatchSequenceEndpointOptions } from '../../../types/index.js';

export const PATH = '/reel/reel_watch_sequence';

/**
 * Builds a `/reel/reel_watch_sequence` request payload.
 * @param opts - The options to use.
 * @returns The payload.
 */
export function build(opts: ReelWatchSequenceEndpointOptions): IReelWatchSequenceRequest {
  return {
    sequenceParams: opts.sequence_params
  };
}

LuanRT/YouTube.js/blob/main/src/core/endpoints/reel/index.ts:

export * as ReelItemWatchEndpoint from './ReelItemWatchEndpoint.js';
export * as ReelWatchSequenceEndpoint from './ReelWatchSequenceEndpoint.js';

LuanRT/YouTube.js/blob/main/src/core/endpoints/subscription/SubscribeEndpoint.ts:

import type { ISubscribeRequest, SubscribeEndpointOptions } from '../../../types/index.js';

export const PATH = '/subscription/subscribe';

/**
 * Builds a `/subscription/subscribe` endpoint payload.
 * @param options - The options to use.
 * @returns The payload.
 */
export function build(options: SubscribeEndpointOptions): ISubscribeRequest {
  return {
    channelIds: options.channel_ids,
    ...{
      client: options.client,
      params: options.params
    }
  };
}

LuanRT/YouTube.js/blob/main/src/core/endpoints/subscription/UnsubscribeEndpoint.ts:

import type { IUnsubscribeRequest, UnsubscribeEndpointOptions } from '../../../types/index.js';

export const PATH = '/subscription/unsubscribe';

/**
 * Builds a `/subscription/unsubscribe` endpoint payload.
 * @param options - The options to use.
 * @returns The payload.
 */
export function build(options: UnsubscribeEndpointOptions): IUnsubscribeRequest {
  return {
    channelIds: options.channel_ids,
    ...{
      client: options.client,
      params: options.params
    }
  };
}

LuanRT/YouTube.js/blob/main/src/core/endpoints/subscription/index.ts:

export * as SubscribeEndpoint from './SubscribeEndpoint.js';
export * as UnsubscribeEndpoint from './UnsubscribeEndpoint.js';

LuanRT/YouTube.js/blob/main/src/core/endpoints/upload/CreateVideoEndpoint.ts:

import type { ICreateVideoRequest, CreateVideoEndpointOptions } from '../../../types/index.js';

export const PATH = '/upload/createvideo';

/**
 * Builds a `/upload/createvideo` request payload.
 * @param opts - The options to use.
 * @returns The payload.
 */
export function build(opts: CreateVideoEndpointOptions): ICreateVideoRequest {
  return {
    resourceId: {
      scottyResourceId: {
        id: opts.resource_id.scotty_resource_id.id
      }
    },
    frontendUploadId: opts.frontend_upload_id,
    initialMetadata: {
      title: {
        newTitle: opts.initial_metadata.title.new_title
      },
      description: {
        newDescription: opts.initial_metadata.description.new_description,
        shouldSegment: opts.initial_metadata.description.should_segment
      },
      privacy: {
        newPrivacy: opts.initial_metadata.privacy.new_privacy
      },
      draftState: {
        isDraft: !!opts.initial_metadata.draft_state.is_draft
      }
    },
    ...{
      client: opts.client
    }
  };
}

LuanRT/YouTube.js/blob/main/src/core/endpoints/upload/index.ts:

export * as CreateVideoEndpoint from './CreateVideoEndpoint.js';

LuanRT/YouTube.js/blob/main/src/core/index.ts:

export { default as Session } from './Session.js';
export * from './Session.js';

export { default as Actions } from './Actions.js';
export * from './Actions.js';

export { default as Player } from './Player.js';
export * from './Player.js';

export { default as OAuth2 } from './OAuth2.js';
export * from './OAuth2.js';

export * as Clients from './clients/index.js';
export * as Endpoints from './endpoints/index.js';
export * as Managers from './managers/index.js';
export * as Mixins from './mixins/index.js';

LuanRT/YouTube.js/blob/main/src/core/managers/AccountManager.ts:

import type { Actions, ApiResponse } from '../index.js';

import AccountInfo from '../../parser/youtube/AccountInfo.js';
import Analytics from '../../parser/youtube/Analytics.js';
import Settings from '../../parser/youtube/Settings.js';
import TimeWatched from '../../parser/youtube/TimeWatched.js';

import * as Proto from '../../proto/index.js';
import { InnertubeError } from '../../utils/Utils.js';
import { Account, BrowseEndpoint, Channel } from '../endpoints/index.js';

export default class AccountManager {
  #actions: Actions;

  channel: {
    editName: (new_name: string) => Promise<ApiResponse>;
    editDescription: (new_description: string) => Promise<ApiResponse>;
    getBasicAnalytics: () => Promise<Analytics>;
  };

  constructor(actions: Actions) {
    this.#actions = actions;

    this.channel = {
      /**
       * Edits channel name.
       * @param new_name - The new channel name.
       */
      editName: (new_name: string) => {
        if (!this.#actions.session.logged_in)
          throw new InnertubeError('You must be signed in to perform this operation.');

        return this.#actions.execute(
          Channel.EditNameEndpoint.PATH,
          Channel.EditNameEndpoint.build({
            given_name: new_name
          })
        );
      },
      /**
       * Edits channel description.
       * @param new_description - The new description.
       */
      editDescription: (new_description: string) => {
        if (!this.#actions.session.logged_in)
          throw new InnertubeError('You must be signed in to perform this operation.');

        return this.#actions.execute(
          Channel.EditDescriptionEndpoint.PATH,
          Channel.EditDescriptionEndpoint.build({
            given_description: new_description
          })
        );
      },
      /**
       * Retrieves basic channel analytics.
       */
      getBasicAnalytics: () => this.getAnalytics()
    };
  }

  /**
   * Retrieves channel info.
   */
  async getInfo(): Promise<AccountInfo> {
    if (!this.#actions.session.logged_in)
      throw new InnertubeError('You must be signed in to perform this operation.');

    const response = await this.#actions.execute(
      Account.AccountListEndpoint.PATH,
      Account.AccountListEndpoint.build()
    );

    return new AccountInfo(response);
  }

  /**
   * Retrieves time watched statistics.
   */
  async getTimeWatched(): Promise<TimeWatched> {
    const response = await this.#actions.execute(
      BrowseEndpoint.PATH, BrowseEndpoint.build({
        browse_id: 'SPtime_watched',
        client: 'ANDROID'
      })
    );

    return new TimeWatched(response);
  }

  /**
   * Opens YouTube settings.
   */
  async getSettings(): Promise<Settings> {
    const response = await this.#actions.execute(
      BrowseEndpoint.PATH, BrowseEndpoint.build({
        browse_id: 'SPaccount_overview'
      })
    );
    return new Settings(this.#actions, response);
  }

  /**
   * Retrieves basic channel analytics.
   */
  async getAnalytics(): Promise<Analytics> {
    const info = await this.getInfo();

    const response = await this.#actions.execute(
      BrowseEndpoint.PATH, BrowseEndpoint.build({
        browse_id: 'FEanalytics_screen',
        params: Proto.encodeChannelAnalyticsParams(info.footers?.endpoint.payload.browseId),
        client: 'ANDROID'
      })
    );

    return new Analytics(response);
  }
}

LuanRT/YouTube.js/blob/main/src/core/managers/InteractionManager.ts:

import * as Proto from '../../proto/index.js';

import { throwIfMissing } from '../../utils/Utils.js';
import { LikeEndpoint, DislikeEndpoint, RemoveLikeEndpoint } from '../endpoints/like/index.js';
import { SubscribeEndpoint, UnsubscribeEndpoint } from '../endpoints/subscription/index.js';
import { CreateCommentEndpoint, PerformCommentActionEndpoint } from '../endpoints/comment/index.js';
import { ModifyChannelPreferenceEndpoint } from '../endpoints/notification/index.js';

import type { Actions, ApiResponse } from '../index.js';

export default class InteractionManager {
  #actions: Actions;

  constructor(actions: Actions) {
    this.#actions = actions;
  }

  /**
   * Likes a given video.
   * @param video_id - The video ID
   */
  async like(video_id: string): Promise<ApiResponse> {
    throwIfMissing({ video_id });

    if (!this.#actions.session.logged_in)
      throw new Error('You must be signed in to perform this operation.');

    const action = await this.#actions.execute(
      LikeEndpoint.PATH, LikeEndpoint.build({
        client: 'ANDROID',
        target: { video_id }
      })
    );

    return action;
  }

  /**
   * Dislikes a given video.
   * @param video_id - The video ID
   */
  async dislike(video_id: string): Promise<ApiResponse> {
    throwIfMissing({ video_id });

    if (!this.#actions.session.logged_in)
      throw new Error('You must be signed in to perform this operation.');

    const action = await this.#actions.execute(
      DislikeEndpoint.PATH, DislikeEndpoint.build({
        client: 'ANDROID',
        target: { video_id }
      })
    );

    return action;
  }

  /**
   * Removes a like/dislike.
   * @param video_id - The video ID
   */
  async removeRating(video_id: string): Promise<ApiResponse> {
    throwIfMissing({ video_id });

    if (!this.#actions.session.logged_in)
      throw new Error('You must be signed in to perform this operation.');

    const action = await this.#actions.execute(
      RemoveLikeEndpoint.PATH, RemoveLikeEndpoint.build({
        client: 'ANDROID',
        target: { video_id }
      })
    );

    return action;
  }

  /**
   * Subscribes to a given channel.
   * @param channel_id - The channel ID
   */
  async subscribe(channel_id: string): Promise<ApiResponse> {
    throwIfMissing({ channel_id });

    if (!this.#actions.session.logged_in)
      throw new Error('You must be signed in to perform this operation.');

    const action = await this.#actions.execute(
      SubscribeEndpoint.PATH, SubscribeEndpoint.build({
        client: 'ANDROID',
        channel_ids: [ channel_id ],
        params: 'EgIIAhgA'
      })
    );

    return action;
  }

  /**
   * Unsubscribes from a given channel.
   * @param channel_id - The channel ID
   */
  async unsubscribe(channel_id: string): Promise<ApiResponse> {
    throwIfMissing({ channel_id });

    if (!this.#actions.session.logged_in)
      throw new Error('You must be signed in to perform this operation.');

    const action = await this.#actions.execute(
      UnsubscribeEndpoint.PATH, UnsubscribeEndpoint.build({
        client: 'ANDROID',
        channel_ids: [ channel_id ],
        params: 'CgIIAhgA'
      })
    );

    return action;
  }

  /**
   * Posts a comment on a given video.
   * @param video_id - The video ID
   * @param text - The comment text
   */
  async comment(video_id: string, text: string): Promise<ApiResponse> {
    throwIfMissing({ video_id, text });

    if (!this.#actions.session.logged_in)
      throw new Error('You must be signed in to perform this operation.');

    const action = await this.#actions.execute(
      CreateCommentEndpoint.PATH, CreateCommentEndpoint.build({
        comment_text: text,
        create_comment_params: Proto.encodeCommentParams(video_id),
        client: 'ANDROID'
      })
    );

    return action;
  }

  /**
   * Translates a given text using YouTube's comment translate feature.
   *
   * @param target_language - an ISO language code
   * @param args - optional arguments
   */
  async translate(text: string, target_language: string, args: { video_id?: string; comment_id?: string; } = {}) {
    throwIfMissing({ text, target_language });

    const target_action = Proto.encodeCommentActionParams(22, { text, target_language, ...args });

    const response = await this.#actions.execute(
      PerformCommentActionEndpoint.PATH, PerformCommentActionEndpoint.build({
        client: 'ANDROID',
        actions: [ target_action ]
      })
    );

    const mutation = response.data.frameworkUpdates.entityBatchUpdate.mutations[0].payload.commentEntityPayload;

    return {
      success: response.success,
      status_code: response.status_code,
      translated_content: mutation.translatedContent.content,
      data: response.data
    };
  }

  /**
   * Changes notification preferences for a given channel.
   * Only works with channels you are subscribed to.
   * @param channel_id - The channel ID.
   * @param type - The notification type.
   */
  async setNotificationPreferences(channel_id: string, type: 'PERSONALIZED' | 'ALL' | 'NONE'): Promise<ApiResponse> {
    throwIfMissing({ channel_id, type });

    if (!this.#actions.session.logged_in)
      throw new Error('You must be signed in to perform this operation.');

    const pref_types = {
      PERSONALIZED: 1,
      ALL: 2,
      NONE: 3
    };

    if (!Object.keys(pref_types).includes(type.toUpperCase()))
      throw new Error(`Invalid notification preference type: ${type}`);

    const action = await this.#actions.execute(
      ModifyChannelPreferenceEndpoint.PATH, ModifyChannelPreferenceEndpoint.build({
        client: 'WEB',
        params: Proto.encodeNotificationPref(channel_id, pref_types[type.toUpperCase() as keyof typeof pref_types])
      })
    );

    return action;
  }
}

LuanRT/YouTube.js/blob/main/src/core/managers/PlaylistManager.ts:

import { InnertubeError, throwIfMissing } from '../../utils/Utils.js';
import { EditPlaylistEndpoint } from '../endpoints/browse/index.js';
import { BrowseEndpoint } from '../endpoints/index.js';
import { CreateEndpoint, DeleteEndpoint } from '../endpoints/playlist/index.js';
import Playlist from '../../parser/youtube/Playlist.js';

import type { Actions } from '../index.js';
import type { Feed } from '../mixins/index.js';
import type { EditPlaylistEndpointOptions } from '../../types/index.js';

export default class PlaylistManager {
  #actions: Actions;

  constructor(actions: Actions) {
    this.#actions = actions;
  }

  /**
   * Creates a playlist.
   * @param title - The title of the playlist.
   * @param video_ids - An array of video IDs to add to the playlist.
   */
  async create(title: string, video_ids: string[]): Promise<{ success: boolean; status_code: number; playlist_id?: string; data: any }> {
    throwIfMissing({ title, video_ids });

    if (!this.#actions.session.logged_in)
      throw new InnertubeError('You must be signed in to perform this operation.');

    const response = await this.#actions.execute(
      CreateEndpoint.PATH, CreateEndpoint.build({
        ids: video_ids,
        title
      })
    );

    return {
      success: response.success,
      status_code: response.status_code,
      playlist_id: response.data.playlistId,
      data: response.data
    };
  }

  /**
   * Deletes a given playlist.
   * @param playlist_id - The playlist ID.
   */
  async delete(playlist_id: string): Promise<{ playlist_id: string; success: boolean; status_code: number; data: any }> {
    throwIfMissing({ playlist_id });

    if (!this.#actions.session.logged_in)
      throw new InnertubeError('You must be signed in to perform this operation.');

    const response = await this.#actions.execute(
      DeleteEndpoint.PATH, DeleteEndpoint.build({
        playlist_id
      })
    );

    return {
      playlist_id,
      success: response.success,
      status_code: response.status_code,
      data: response.data
    };
  }

  /**
   * Adds videos to a given playlist.
   * @param playlist_id - The playlist ID.
   * @param video_ids - An array of video IDs to add to the playlist.
   */
  async addVideos(playlist_id: string, video_ids: string[]): Promise<{ playlist_id: string; action_result: any }> {
    throwIfMissing({ playlist_id, video_ids });

    if (!this.#actions.session.logged_in)
      throw new InnertubeError('You must be signed in to perform this operation.');

    const response = await this.#actions.execute(
      EditPlaylistEndpoint.PATH, EditPlaylistEndpoint.build({
        actions: video_ids.map((id) => ({
          action: 'ACTION_ADD_VIDEO',
          added_video_id: id
        })),
        playlist_id
      })
    );

    return {
      playlist_id,
      action_result: response.data.actions // TODO: implement actions in the parser
    };
  }

  /**
   * Removes videos from a given playlist.
   * @param playlist_id - The playlist ID.
   * @param video_ids - An array of video IDs to remove from the playlist.
   * @param use_set_video_ids - Option to remove videos using set video IDs.
   */
  async removeVideos(playlist_id: string, video_ids: string[], use_set_video_ids = false): Promise<{ playlist_id: string; action_result: any }> {
    throwIfMissing({ playlist_id, video_ids });

    if (!this.#actions.session.logged_in)
      throw new InnertubeError('You must be signed in to perform this operation.');

    const info = await this.#actions.execute(
      BrowseEndpoint.PATH, { ...BrowseEndpoint.build({ browse_id: `VL${playlist_id}` }), parse: true }
    );

    const playlist = new Playlist(this.#actions, info, true);

    if (!playlist.info.is_editable)
      throw new InnertubeError('This playlist cannot be edited.', playlist_id);

    const payload: EditPlaylistEndpointOptions = { playlist_id, actions: [] };

    const getSetVideoIds = async (pl: Feed): Promise<void> => {
      const key_id = use_set_video_ids ? 'set_video_id' : 'id';
      const videos = pl.videos.filter((video) => video_ids.includes(video.key(key_id).string()));

      videos.forEach((video) =>
        payload.actions.push({
          action: 'ACTION_REMOVE_VIDEO',
          set_video_id: video.key('set_video_id').string()
        })
      );

      if (payload.actions.length < video_ids.length) {
        const next = await pl.getContinuation();
        return getSetVideoIds(next);
      }
    };

    await getSetVideoIds(playlist);

    if (!payload.actions.length)
      throw new InnertubeError('Given video ids were not found in this playlist.', video_ids);

    const response = await this.#actions.execute(
      EditPlaylistEndpoint.PATH, EditPlaylistEndpoint.build(payload)
    );

    return {
      playlist_id,
      action_result: response.data.actions // TODO: implement actions in the parser
    };
  }

  /**
   * Moves a video to a new position within a given playlist.
   * @param playlist_id - The playlist ID.
   * @param moved_video_id - The video ID to move.
   * @param predecessor_video_id - The video ID to move the moved video before.
   */
  async moveVideo(playlist_id: string, moved_video_id: string, predecessor_video_id: string): Promise<{ playlist_id: string; action_result: any; }> {
    throwIfMissing({ playlist_id, moved_video_id, predecessor_video_id });

    if (!this.#actions.session.logged_in)
      throw new InnertubeError('You must be signed in to perform this operation.');

    const info = await this.#actions.execute(
      BrowseEndpoint.PATH, { ...BrowseEndpoint.build({ browse_id: `VL${playlist_id}` }), parse: true }
    );

    const playlist = new Playlist(this.#actions, info, true);

    if (!playlist.info.is_editable)
      throw new InnertubeError('This playlist cannot be edited.', playlist_id);

    const payload: EditPlaylistEndpointOptions = { playlist_id, actions: [] };

    let set_video_id_0: string | undefined, set_video_id_1: string | undefined;

    const getSetVideoIds = async (pl: Feed): Promise<void> => {
      const video_0 = pl.videos.find((video) => moved_video_id === video.key('id').string());
      const video_1 = pl.videos.find((video) => predecessor_video_id === video.key('id').string());

      set_video_id_0 = set_video_id_0 || video_0?.key('set_video_id').string();
      set_video_id_1 = set_video_id_1 || video_1?.key('set_video_id').string();

      if (!set_video_id_0 || !set_video_id_1) {
        const next = await pl.getContinuation();
        return getSetVideoIds(next);
      }
    };

    await getSetVideoIds(playlist);

    payload.actions.push({
      action: 'ACTION_MOVE_VIDEO_AFTER',
      set_video_id: set_video_id_0,
      moved_set_video_id_predecessor: set_video_id_1
    });

    const response = await this.#actions.execute(
      EditPlaylistEndpoint.PATH, EditPlaylistEndpoint.build(payload)
    );

    return {
      playlist_id,
      action_result: response.data.actions // TODO: implement actions in the parser
    };
  }

  /**
   * Sets the name (title) for the given playlist.
   * @param playlist_id - The playlist ID.
   * @param name - The name / title to use for the playlist.
   */
  async setName(playlist_id: string, name: string): Promise<{ playlist_id: string; action_result: any; }> {
    throwIfMissing({ playlist_id, name });

    if (!this.#actions.session.logged_in)
      throw new InnertubeError('You must be signed in to perform this operation.');

    const payload: EditPlaylistEndpointOptions = { playlist_id, actions: [] };

    payload.actions.push({
      action: 'ACTION_SET_PLAYLIST_NAME',
      playlist_name: name
    });

    const response = await this.#actions.execute(
      EditPlaylistEndpoint.PATH, EditPlaylistEndpoint.build(payload)
    );

    return {
      playlist_id,
      action_result: response.data.actions
    };
  }

  /**
   * Sets the description for the given playlist.
   * @param playlist_id - The playlist ID.
   * @param description - The description to use for the playlist.
   */
  async setDescription(playlist_id: string, description: string): Promise<{ playlist_id: string; action_result: any; }> {
    throwIfMissing({ playlist_id, description });

    if (!this.#actions.session.logged_in)
      throw new InnertubeError('You must be signed in to perform this operation.');

    const payload: EditPlaylistEndpointOptions = { playlist_id, actions: [] };

    payload.actions.push({
      action: 'ACTION_SET_PLAYLIST_DESCRIPTION',
      playlist_description: description
    });

    const response = await this.#actions.execute(
      EditPlaylistEndpoint.PATH, EditPlaylistEndpoint.build(payload)
    );

    return {
      playlist_id,
      action_result: response.data.actions
    };
  }
}

LuanRT/YouTube.js/blob/main/src/core/managers/index.ts:

export { default as AccountManager } from './AccountManager.js';
export { default as PlaylistManager } from './PlaylistManager.js';
export { default as InteractionManager } from './InteractionManager.js';

LuanRT/YouTube.js/blob/main/src/core/mixins/Feed.ts:

import { Parser, ReloadContinuationItemsCommand } from '../../parser/index.js';
import { concatMemos, InnertubeError } from '../../utils/Utils.js';

import BackstagePost from '../../parser/classes/BackstagePost.js';
import SharedPost from '../../parser/classes/SharedPost.js';
import Channel from '../../parser/classes/Channel.js';
import CompactVideo from '../../parser/classes/CompactVideo.js';
import GridChannel from '../../parser/classes/GridChannel.js';
import GridPlaylist from '../../parser/classes/GridPlaylist.js';
import GridVideo from '../../parser/classes/GridVideo.js';
import LockupView from '../../parser/classes/LockupView.js';
import Playlist from '../../parser/classes/Playlist.js';
import PlaylistPanelVideo from '../../parser/classes/PlaylistPanelVideo.js';
import PlaylistVideo from '../../parser/classes/PlaylistVideo.js';
import Post from '../../parser/classes/Post.js';
import ReelItem from '../../parser/classes/ReelItem.js';
import ReelShelf from '../../parser/classes/ReelShelf.js';
import RichShelf from '../../parser/classes/RichShelf.js';
import Shelf from '../../parser/classes/Shelf.js';
import Tab from '../../parser/classes/Tab.js';
import Video from '../../parser/classes/Video.js';

import AppendContinuationItemsAction from '../../parser/classes/actions/AppendContinuationItemsAction.js';
import ContinuationItem from '../../parser/classes/ContinuationItem.js';
import TwoColumnBrowseResults from '../../parser/classes/TwoColumnBrowseResults.js';
import TwoColumnSearchResults from '../../parser/classes/TwoColumnSearchResults.js';
import WatchCardCompactVideo from '../../parser/classes/WatchCardCompactVideo.js';

import type { ApiResponse, Actions } from '../index.js';
import type {
  Memo, ObservedArray,
  SuperParsedResult, YTNode
} 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 { IParsedResponse } from '../../parser/types/index.js';

export default class Feed<T extends IParsedResponse = IParsedResponse> {
  #page: T;
  #continuation?: ObservedArray<ContinuationItem>;
  #actions: Actions;
  #memo: Memo;

  constructor(actions: Actions, response: ApiResponse | IParsedResponse, already_parsed = false) {
    if (this.#isParsed(response) || already_parsed) {
      this.#page = response as T;
    } else {
      this.#page = Parser.parseResponse<T>(response.data);
    }

    const memo = concatMemos(...[
      this.#page.contents_memo,
      this.#page.continuation_contents_memo,
      this.#page.on_response_received_commands_memo,
      this.#page.on_response_received_endpoints_memo,
      this.#page.on_response_received_actions_memo,
      this.#page.sidebar_memo,
      this.#page.header_memo
    ]);

    if (!memo)
      throw new InnertubeError('No memo found in feed');

    this.#memo = memo;
    this.#actions = actions;
  }

  #isParsed(response: IParsedResponse | ApiResponse): response is IParsedResponse {
    return !('data' in response);
  }

  /**
   * Get all videos on a given page via memo
   */
  static getVideosFromMemo(memo: Memo) {
    return memo.getType(
      Video,
      GridVideo,
      ReelItem,
      CompactVideo,
      PlaylistVideo,
      PlaylistPanelVideo,
      WatchCardCompactVideo
    );
  }

  /**
   * Get all playlists on a given page via memo
   */
  static getPlaylistsFromMemo(memo: Memo) {
    const playlists: ObservedArray<Playlist | GridPlaylist | LockupView> = memo.getType(Playlist, GridPlaylist);

    const lockup_views = memo.getType(LockupView)
      .filter((lockup) => {
        return [ 'PLAYLIST', 'ALBUM', 'PODCAST' ].includes(lockup.content_type);
      });

    if (lockup_views.length > 0) {
      playlists.push(...lockup_views);
    }

    return playlists;
  }

  /**
   * Get all the videos in the feed
   */
  get videos() {
    return Feed.getVideosFromMemo(this.#memo);
  }

  /**
   * Get all the community posts in the feed
   */
  get posts() {
    return this.#memo.getType(BackstagePost, Post, SharedPost);
  }

  /**
   * Get all the channels in the feed
   */
  get channels() {
    return this.#memo.getType(Channel, GridChannel);
  }

  /**
   * Get all playlists in the feed
   */
  get playlists() {
    return Feed.getPlaylistsFromMemo(this.#memo);
  }

  get memo() {
    return this.#memo;
  }

  /**
   * Returns contents from the page.
   */
  get page_contents(): SectionList | MusicQueue | RichGrid | ReloadContinuationItemsCommand {
    const tab_content = this.#memo.getType(Tab)?.first().content;
    const reload_continuation_items = this.#memo.getType(ReloadContinuationItemsCommand).first();
    const append_continuation_items = this.#memo.getType(AppendContinuationItemsAction).first();

    return tab_content || reload_continuation_items || append_continuation_items;
  }

  /**
   * Returns all segments/sections from the page.
   */
  get shelves() {
    return this.#memo.getType(Shelf, RichShelf, ReelShelf);
  }

  /**
   * Finds shelf by title.
   */
  getShelf(title: string) {
    return this.shelves.get({ title });
  }

  /**
   * Returns secondary contents from the page.
   */
  get secondary_contents(): SuperParsedResult<YTNode> | undefined {
    if (!this.#page.contents?.is_node)
      return undefined;

    const node = this.#page.contents?.item();

    if (!node.is(TwoColumnBrowseResults, TwoColumnSearchResults))
      return undefined;

    return node.secondary_contents;
  }

  get actions(): Actions {
    return this.#actions;
  }

  /**
   * Get the original page data
   */
  get page(): T {
    return this.#page;
  }

  /**
   * Checks if the feed has continuation.
   */
  get has_continuation(): boolean {
    return this.#getBodyContinuations().length > 0;
  }

  /**
   * Retrieves continuation data as it is.
   */
  async getContinuationData(): Promise<T | undefined> {
    if (this.#continuation) {
      if (this.#continuation.length === 0)
        throw new InnertubeError('There are no continuations.');

      const response = await this.#continuation[0].endpoint.call<T>(this.#actions, { parse: true });

      return response;
    }

    this.#continuation = this.#getBodyContinuations();

    if (this.#continuation)
      return this.getContinuationData();
  }

  /**
   * Retrieves next batch of contents and returns a new {@link Feed} object.
   */
  async getContinuation(): Promise<Feed<T>> {
    const continuation_data = await this.getContinuationData();
    if (!continuation_data)
      throw new InnertubeError('Could not get continuation data');
    return new Feed<T>(this.actions, continuation_data, true);
  }

  #getBodyContinuations(): ObservedArray<ContinuationItem> {
    if (this.#page.header_memo) {
      const header_continuations = this.#page.header_memo.getType(ContinuationItem);
      return this.#memo.getType(ContinuationItem).filter(
        (continuation) => !header_continuations.includes(continuation)
      ) as ObservedArray<ContinuationItem>;
    }
    return this.#memo.getType(ContinuationItem);
  }
}

LuanRT/YouTube.js/blob/main/src/core/mixins/FilterableFeed.ts:

import Feed from './Feed.js';
import ChipCloudChip from '../../parser/classes/ChipCloudChip.js';
import FeedFilterChipBar from '../../parser/classes/FeedFilterChipBar.js';
import { InnertubeError } from '../../utils/Utils.js';

import type { ObservedArray } from '../../parser/helpers.js';
import type { IParsedResponse } from '../../parser/types/index.js';
import type { ApiResponse, Actions } from '../index.js';

export default class FilterableFeed<T extends IParsedResponse> extends Feed<T> {
  #chips?: ObservedArray<ChipCloudChip>;

  constructor(actions: Actions, data: ApiResponse | T, already_parsed = false) {
    super(actions, data, already_parsed);
  }

  /**
   * Returns the filter chips.
   */
  get filter_chips(): ObservedArray<ChipCloudChip> {
    if (this.#chips)
      return this.#chips || [];

    if (this.memo.getType(FeedFilterChipBar)?.length > 1)
      throw new InnertubeError('There are too many feed filter chipbars, you\'ll need to find the correct one yourself in this.page');

    if (this.memo.getType(FeedFilterChipBar)?.length === 0)
      throw new InnertubeError('There are no feed filter chipbars');

    this.#chips = this.memo.getType(ChipCloudChip);

    return this.#chips || [];
  }

  /**
   * Returns available filters.
   */
  get filters(): string[] {
    return this.filter_chips.map((chip) => chip.text.toString()) || [];
  }

  /**
   * Applies given filter and returns a new {@link Feed} object.
   */
  async getFilteredFeed(filter: string | ChipCloudChip): Promise<Feed<T>> {
    let target_filter: ChipCloudChip | undefined;

    if (typeof filter === 'string') {
      if (!this.filters.includes(filter))
        throw new InnertubeError('Filter not found', { available_filters: this.filters });
      target_filter = this.filter_chips.find((chip) => chip.text.toString() === filter);
    } else if (filter.type === 'ChipCloudChip') {
      target_filter = filter;
    } else {
      throw new InnertubeError('Invalid filter');
    }

    if (!target_filter)
      throw new InnertubeError('Filter not found');

    if (target_filter.is_selected)
      return this;

    const response = await target_filter.endpoint?.call(this.actions, { parse: true });

    if (!response)
      throw new InnertubeError('Failed to get filtered feed');

    return new Feed(this.actions, response, true);
  }
}

LuanRT/YouTube.js/blob/main/src/core/mixins/MediaInfo.ts:

import { Constants, FormatUtils } from '../../utils/index.js';
import { InnertubeError } from '../../utils/Utils.js';
import { getStreamingInfo } from '../../utils/StreamingInfo.js';

import { Parser } from '../../parser/index.js';
import { TranscriptInfo } from '../../parser/youtube/index.js';
import ContinuationItem from '../../parser/classes/ContinuationItem.js';
import PlayerMicroformat from '../../parser/classes/PlayerMicroformat.js';
import MicroformatData from '../../parser/classes/MicroformatData.js';

import type { ApiResponse, Actions } from '../index.js';
import type { INextResponse, IPlayabilityStatus, IPlaybackTracking, IPlayerConfig, IPlayerResponse, IStreamingData } from '../../parser/index.js';
import type { DownloadOptions, FormatFilter, FormatOptions, URLTransformer } from '../../types/FormatUtils.js';
import type Format from '../../parser/classes/misc/Format.js';
import type { DashOptions } from '../../types/DashOptions.js';
import type { ObservedArray } from '../../parser/helpers.js';

import type CardCollection from '../../parser/classes/CardCollection.js';
import type Endscreen from '../../parser/classes/Endscreen.js';
import type PlayerAnnotationsExpanded from '../../parser/classes/PlayerAnnotationsExpanded.js';
import type PlayerCaptionsTracklist from '../../parser/classes/PlayerCaptionsTracklist.js';
import type PlayerLiveStoryboardSpec from '../../parser/classes/PlayerLiveStoryboardSpec.js';
import type PlayerStoryboardSpec from '../../parser/classes/PlayerStoryboardSpec.js';

export default class MediaInfo {
  #page: [IPlayerResponse, INextResponse?];
  #actions: Actions;
  #cpn: string;
  #playback_tracking?: IPlaybackTracking;
  basic_info;
  annotations?: ObservedArray<PlayerAnnotationsExpanded>;
  storyboards?: PlayerStoryboardSpec | PlayerLiveStoryboardSpec;
  endscreen?: Endscreen;
  captions?: PlayerCaptionsTracklist;
  cards?: CardCollection;
  streaming_data?: IStreamingData;
  playability_status?: IPlayabilityStatus;
  player_config?: IPlayerConfig;

  constructor(data: [ApiResponse, ApiResponse?], actions: Actions, cpn: string) {
    this.#actions = actions;

    const info = Parser.parseResponse<IPlayerResponse>(data[0].data.playerResponse ? data[0].data.playerResponse : data[0].data);
    const next = data[1]?.data ? Parser.parseResponse<INextResponse>(data[1].data) : undefined;

    this.#page = [ info, next ];
    this.#cpn = cpn;

    if (info.playability_status?.status === 'ERROR')
      throw new InnertubeError('This video is unavailable', info.playability_status);

    if (info.microformat && !info.microformat?.is(PlayerMicroformat, MicroformatData))
      throw new InnertubeError('Unsupported microformat', info.microformat);

    this.basic_info = { // This type is inferred so no need for an explicit type
      ...info.video_details,
      /**
       * Microformat is a bit redundant, so only
       * a few things there are interesting to us.
       */
      ...{
        embed: info.microformat?.is(PlayerMicroformat) ? info.microformat?.embed : null,
        channel: info.microformat?.is(PlayerMicroformat) ? info.microformat?.channel : null,
        is_unlisted: info.microformat?.is_unlisted,
        is_family_safe: info.microformat?.is_family_safe,
        category: info.microformat?.is(PlayerMicroformat) ? info.microformat?.category : null,
        has_ypc_metadata: info.microformat?.is(PlayerMicroformat) ? info.microformat?.has_ypc_metadata : null,
        start_timestamp: info.microformat?.is(PlayerMicroformat) ? info.microformat.start_timestamp : null,
        end_timestamp: info.microformat?.is(PlayerMicroformat) ? info.microformat.end_timestamp : null,
        view_count: info.microformat?.is(PlayerMicroformat) && isNaN(info.video_details?.view_count as number) ? info.microformat.view_count : info.video_details?.view_count,
        url_canonical: info.microformat?.is(MicroformatData) ? info.microformat?.url_canonical : null,
        tags: info.microformat?.is(MicroformatData) ? info.microformat?.tags : null
      },
      like_count: undefined as number | undefined,
      is_liked: undefined as boolean | undefined,
      is_disliked: undefined as boolean | undefined
    };

    this.annotations = info.annotations;
    this.storyboards = info.storyboards;
    this.endscreen = info.endscreen;
    this.captions = info.captions;
    this.cards = info.cards;
    this.streaming_data = info.streaming_data;
    this.playability_status = info.playability_status;
    this.player_config = info.player_config;
    this.#playback_tracking = info.playback_tracking;
  }

  /**
   * Generates a DASH manifest from the streaming data.
   * @param url_transformer - Function to transform the URLs.
   * @param format_filter - Function to filter the formats.
   * @param options - Additional options to customise the manifest generation
   * @returns DASH manifest
   */
  async toDash(url_transformer?: URLTransformer, format_filter?: FormatFilter, options: DashOptions = { include_thumbnails: false }): Promise<string> {
    const player_response = this.#page[0];

    if (player_response.video_details && (player_response.video_details.is_live)) {
      throw new InnertubeError('Generating DASH manifests for live videos is not supported. Please use the DASH and HLS manifests provided by YouTube in `streaming_data.dash_manifest_url` and `streaming_data.hls_manifest_url` instead.');
    }

    let storyboards;
    let captions;

    if (options.include_thumbnails && player_response.storyboards) {
      storyboards = player_response.storyboards;
    }

    if (typeof options.captions_format === 'string' && player_response.captions?.caption_tracks) {
      captions = player_response.captions.caption_tracks;
    }

    return FormatUtils.toDash(
      this.streaming_data,
      this.page[0].video_details?.is_post_live_dvr,
      url_transformer,
      format_filter,
      this.#cpn,
      this.#actions.session.player,
      this.#actions,
      storyboards,
      captions,
      options
    );
  }

  /**
   * Get a cleaned up representation of the adaptive_formats
   */
  getStreamingInfo(url_transformer?: URLTransformer, format_filter?: FormatFilter) {
    return getStreamingInfo(
      this.streaming_data,
      this.page[0].video_details?.is_post_live_dvr,
      url_transformer,
      format_filter,
      this.cpn,
      this.#actions.session.player,
      this.#actions,
      this.#page[0].storyboards ? this.#page[0].storyboards : undefined
    );
  }

  /**
   * Selects the format that best matches the given options.
   * @param options - Options
   */
  chooseFormat(options: FormatOptions): Format {
    return FormatUtils.chooseFormat(options, this.streaming_data);
  }

  /**
   * Downloads the video.
   * @param options - Download options.
   */
  async download(options: DownloadOptions = {}): Promise<ReadableStream<Uint8Array>> {
    const player_response = this.#page[0];

    if (player_response.video_details && (player_response.video_details.is_live || player_response.video_details.is_post_live_dvr)) {
      throw new InnertubeError('Downloading is not supported for live and Post-Live-DVR videos, as they are split up into 5 second segments that are individual files, which require using a tool such as ffmpeg to stitch them together, so they cannot be returned in a single stream.');
    }

    return FormatUtils.download(options, this.#actions, this.playability_status, this.streaming_data, this.#actions.session.player, this.cpn);
  }

  /**
   * Retrieves the video's transcript.
   * @param video_id - The video id.
   */
  async getTranscript(): Promise<TranscriptInfo> {
    const next_response = this.page[1];

    if (!next_response)
      throw new InnertubeError('Cannot get transcript from basic video info.');

    if (!next_response.engagement_panels)
      throw new InnertubeError('Engagement panels not found. Video likely has no transcript.');

    const transcript_panel = next_response.engagement_panels.get({
      panel_identifier: 'engagement-panel-searchable-transcript'
    });

    if (!transcript_panel)
      throw new InnertubeError('Transcript panel not found. Video likely has no transcript.');

    const transcript_continuation = transcript_panel.content?.as(ContinuationItem);

    if (!transcript_continuation)
      throw new InnertubeError('Transcript continuation not found.');

    const response = await transcript_continuation.endpoint.call(this.actions);

    return new TranscriptInfo(this.actions, response);
  }

  /**
   * Adds video to the watch history.
   */
  async addToWatchHistory(client_name = Constants.CLIENTS.WEB.NAME, client_version = Constants.CLIENTS.WEB.VERSION, replacement = 'https://www.'): Promise<Response> {
    if (!this.#playback_tracking)
      throw new InnertubeError('Playback tracking not available');

    const url_params = {
      cpn: this.#cpn,
      fmt: 251,
      rtn: 0,
      rt: 0
    };

    const url = this.#playback_tracking.videostats_playback_url.replace('https://s.', replacement);

    const response = await this.#actions.stats(url, {
      client_name,
      client_version
    }, url_params);

    return response;
  }

  /**
   * Actions instance.
   */
  get actions(): Actions {
    return this.#actions;
  }

  /**
   * Content Playback Nonce.
   */
  get cpn(): string {
    return this.#cpn;
  }

  /**
   * Original parsed InnerTube response.
   */
  get page(): [IPlayerResponse, INextResponse?] {
    return this.#page;
  }
}

LuanRT/YouTube.js/blob/main/src/core/mixins/TabbedFeed.ts:

import { Feed } from './index.js';
import { InnertubeError } from '../../utils/Utils.js';
import Tab from '../../parser/classes/Tab.js';

import type { Actions, ApiResponse } from '../index.js';
import type { ObservedArray } from '../../parser/helpers.js';
import type { IParsedResponse } from '../../parser/types/ParsedResponse.js';

export default class TabbedFeed<T extends IParsedResponse> extends Feed<T> {
  #tabs?: ObservedArray<Tab>;
  #actions: Actions;

  constructor(actions: Actions, data: ApiResponse | IParsedResponse, already_parsed = false) {
    super(actions, data, already_parsed);
    this.#actions = actions;
    this.#tabs = this.page.contents_memo?.getType(Tab);
  }

  get tabs(): string[] {
    return this.#tabs?.map((tab) => tab.title.toString()) ?? [];
  }

  async getTabByName(title: string): Promise<TabbedFeed<T>> {
    const tab = this.#tabs?.find((tab) => tab.title.toLowerCase() === title.toLowerCase());

    if (!tab)
      throw new InnertubeError(`Tab "${title}" not found`);

    if (tab.selected)
      return this;

    const response = await tab.endpoint.call(this.#actions);

    return new TabbedFeed<T>(this.#actions, response, false);
  }

  async getTabByURL(url: string): Promise<TabbedFeed<T>> {
    const tab = this.#tabs?.find((tab) => tab.endpoint.metadata.url?.split('/').pop() === url);

    if (!tab)
      throw new InnertubeError(`Tab "${url}" not found`);

    if (tab.selected)
      return this;

    const response = await tab.endpoint.call(this.#actions);

    return new TabbedFeed<T>(this.#actions, response, false);
  }

  hasTabWithURL(url: string): boolean {
    return this.#tabs?.some((tab) => tab.endpoint.metadata.url?.split('/').pop() === url) ?? false;
  }

  get title(): string | undefined {
    return this.page.contents_memo?.getType(Tab)?.find((tab) => tab.selected)?.title.toString();
  }
}

LuanRT/YouTube.js/blob/main/src/core/mixins/index.ts:

export { default as Feed } from './Feed.js';
export { default as FilterableFeed } from './FilterableFeed.js';
export { default as TabbedFeed } from './TabbedFeed.js';
export { default as MediaInfo } from './MediaInfo.js';

LuanRT/YouTube.js/blob/main/src/parser/README.md:

# Parser

The parser is responsible for sanitizing and standardizing InnerTube responses while preserving the integrity of the data. 

## Table of Contents

- [Parser](#parser)
  - [Table of Contents](#table-of-contents)
  - [Structure](#structure)
    - [Core](#core)
    - [Clients](#clients)
  - [API](#api)
    - [`parse(data?: RawData, requireArray?: boolean, validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[])`](#parsedata-rawdata-requirearray-boolean-validtypes-ytnodeconstructort--ytnodeconstructort)
    - [`parseResponse(data: IRawResponse): T`](#parseresponsedata-irawresponse-t)
  - [Usage](#usage)
    - [ObservedArray](#observedarray)
    - [SuperParsedResponse](#superparsedresponse)
    - [YTNode](#ytnode)
    - [Type Casting](#type-casting)
    - [Accessing properties without casting](#accessing-properties-without-casting)
    - [Memo](#memo)
  - [Adding new nodes](#adding-new-nodes)
  - [Generating nodes at runtime](#generating-nodes-at-runtime)
  - [How it works](#how-it-works)

## Structure
### Core

* [`/types`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/types) - General response types.
* [`/classes`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/classes) - InnerTube nodes.
* [`generator.ts`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/generator.ts) - Used to generate missing nodes at runtime.
* [`helpers.ts`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/helpers.ts) - Helper functions/classes for the parser.
* [`parser.ts`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/parser.ts) - The core of the parser.
* [`nodes.ts`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/nodes.ts) - Contains a list of all the InnerTube nodes, which is used to determine the appropriate node for a given renderer. It's important to note that this file is automatically generated and should not be edited manually.
* [`misc.ts`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/misc.ts) - Miscellaneous classes. Also automatically generated.

### Clients

The parser itself is not tied to any specific client. Therefore, we have a separate folder for each client that the library supports. These folders are responsible for arranging the parsed data into a format that can be easily consumed and understood. Additionally, the underlying data is also exposed for those who wish to access it.

* [`/youtube`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/youtube) 
* [`/ytmusic`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/ytmusic)
* [`/ytkids`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/ytkids)

## API

* Parser
  * [.parse](#parse)
  * [.parseItem](#parse)
  * [.parseArray](#parse)
  * [.parseResponse](#parseresponse) 

<a name="parse"></a>

### `parse(data?: RawData, requireArray?: boolean, validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[])`

Responsible for parsing individual nodes.

| Param | Type | Description |
| --- | --- | --- |
| data | `RawData` | The data to parse |
| requireArray | `?boolean` | Whether the response should be an array |
| validTypes | `YTNodeConstructor<T> \| YTNodeConstructor<T>[] \| undefined` | Types of `YTNode` allowed |

- If `requireArray` is `true`, the response will be an `ObservedArray<YTNodes>`.
- If `validTypes` is `undefined`, the response will be an array of YTNodes.
- If `validTypes` is an array, the response will be an array of YTNodes that are of the types specified in the array.
- If `validTypes` is a single type, the response will be an array of YTNodes that are of the type specified.

If you do not specify `requireArray`, the return type of the function will not be known at runtime. Therefore, to gain access to the response, we return it wrapped in a helper, `SuperParsedResponse`.

You may use the `Parser#parseArray` and `Parser#parseItem` methods to parse the response in a deterministic way.

<a name="parseresponse"></a>

### `parseResponse(data: IRawResponse): T`

Unlike `parse`, this can be used to parse the entire response object.

| Param | Type | Description |
| --- | --- | --- |
| data | `IRawResponse` | Raw InnerTube response |

## Usage

### ObservedArray
You can utilize an `ObservedArray<T extends YTNode>` as a regular array, but it also offers further methods for accessing and casting values in a type-safe manner.

```ts
// For example, we have a feed, and want all the videos:
const feed = new ObservedArray<YTNode>([...feed.contents]);

// Here, we use the filterType method to retrieve only GridVideo items from the feed.
const videos = feed.filterType(GridVideo);
// `videos` is now a GridVideo[] array.

// Alternatively, we can use firstOfType to retrieve the first GridVideo item from the feed.
const firstVideo = feed.firstOfType(GridVideo);

// If we want to make sure that all elements in the `feed` array are of the `GridVideo` type, we can use the `as` method to cast the entire array to a `GridVideo[]` type. If the cast fails because of non-GridVideo items, an exception is thrown.
const allVideos = feed.as(GridVideo);

// Note that ObservedArray provides additional methods beyond what's shown here, which we use internally. For more information, see the source code or documentation.

SuperParsedResponse

Represents a parsed response in an unknown state. Either a YTNode, an ObservedArray<YTNode>, or null. To extract the actual value, you must first assert the type and unwrap the response.

// First, parse the data and store it in `response`.
const response = Parser.parse(data);

// Check whether `response` is a YTNode.
if (response.is_item) {
  // If so, we can assert that it is a YTNode and retrieve it.
  const node = response.item();
}

// Check whether `response` is an ObservedArray<YTNode>.
if (response.is_array) {
  // If so, we can assert that it is an ObservedArray<YTNode> and retrieve its contents as an array of YTNode objects.
  const nodes = response.array();
}

// Finally, to check if `response` is a null value, use the `is_null` getter.
const is_null = response.is_null;

YTNode

All renderers returned by InnerTube are converted to this generic class and then extended for the specific renderers. This class is what allows us a type-safe way to use data returned by the InnerTube API.

Here's how to use this class to access returned data:

Type Casting

// We can cast a YTNode to a child class of YTNode
const results = node.as(TwoColumnSearchResults);
// This will throw an error if the node is not a TwoColumnSearchResults.
// Therefore, we may want to check for the type of the node before casting.
if (node.is(TwoColumnSearchResults)) {
  // We do not need to recast the node; it is already a TwoColumnSearchResults after calling is() and using it in the branch where is() returns true.
  const results = node;
}

// Sometimes we can expect multiple types of nodes, we can just pass all possible types as params.
const results = node.as(TwoColumnSearchResults, VideoList);
// The type of `results` will now be `TwoColumnSearchResults | VideoList`

// Similarly, we can check if the node is of a certain type.
if (node.is(TwoColumnSearchResults, VideoList)) {
  // // Again, no casting is needed; the node is already of the correct type.
  const results = node;
}

Accessing properties without casting

Sometimes multiple nodes have the same properties, and we don't want to check the type of the node before accessing the property. For example, the property 'contents' is used by many node types, and we may add more in the future. As such, we want to only assert the property instead of casting to a specific type.

// Accessing a property on a node when you aren't sure if it exists.
const prop = node.key("contents");

// This returns the value wrapped into a `Maybe` type, which you can use to determine the type of the value.
// However, this throws an error if the key doesn't exist, so we may want to check for the key before accessing it.
if (node.hasKey("contents")) {
  const prop = node.key("contents");
}

// We can assert the type of the value.
const prop = node.key("contents");
if (prop.isString()) {
  const value = prop.string();
}

// We can do more complex assertions, like checking for instanceof.
const prop = node.key("contents");
if (prop.isInstanceOf(Text)) {
  const text = prop.instanceOf(Text);
  
  // Then use the value as the given type.
  text.runs.forEach(run => {
    console.log(run.text);
  });
}

// There are special methods for use with the parser, such as getting the value as a YTNode.
const prop = node.key("contents");
if (prop.isNode()) {
  const node = prop.node();
}

// Like with YTNode, keys can also be checked for YTNode child class types.
const prop = node.key("contents");
if (prop.isNodeOfType(TwoColumnSearchResults)) {
  const results = prop.nodeOfType(TwoColumnSearchResults);
}

// Or we can check for multiple types of nodes.
const prop = node.key("contents");
if (prop.isNodeOfType([TwoColumnSearchResults, VideoList])) {
  const results = prop.nodeOfType<TwoColumnSearchResults | VideoList>([TwoColumnSearchResults, VideoList]);
}

// Sometimes an ObservedArray is returned when working with parsed data.
// We also have a helper for this.
const prop = node.key("contents");
if (prop.isObserved()) {
  const array = prop.observed();

  // Now we can use all the ObservedArray methods as normal, such as finding nodes of a certain type.
  const results = array.filterType(GridVideo);
}

// Other times a SuperParsedResult is returned, like when using the `Parser#parse` method.
const prop = node.key("contents");
if (prop.isParsed()) {
  const result = prop.parsed();

  // SuperParsedResult is another helper for type-safe access to the parsed data.
  // It is explained above with the `Parser#parse` method.
  const results = results.array();
  const videos = results.filterType(Video);
}

// Sometimes we just want to debug something and are not interested in finding the type.
// This will, however, warn you when being used.
const prop = node.key("contents");
const value = prop.any();

// Arrays are a special case, as every element may be of a different type.
// The `arrayOfMaybe` method will return an array of `Maybe`s.
const prop = node.key("contents");
if (prop.isArray()) {
  const array = prop.arrayOfMaybe(); 
  // This will return `Maybe[]`.
}

// Or, if you don't need type safety, you can use the `array` method.
const prop = node.key("contents");
if (prop.isArray()) {
  const array = prop.array();
  // This will return any[].
}

Memo

The Memo class is a helper class for memoizing values in the Parser#parseResponse method. It can be used to conveniently access nodes after parsing the response.

For example, if we'd like to obtain all of the videos from a search result, we can use the Memo#getType method to find them quickly without needing to traverse the entire response.

const response = Parser.parseResponse(data);
const videos = response.contents_memo.getType(Video);
// This returns the nodes as an `ObservedArray<Video>`.

Memo extends Map<string, YTNode[]> and can be used as a regular Map if desired.

Adding new nodes

Instructions can be found here.

Generating nodes at runtime

YouTube constantly updates their client, and sometimes they add new nodes to the response. The parser needs to know about these new nodes in order to parse them correctly. Once a new node is dicovered by the parser, it will attempt to generate a new node class for it.

Using the existing YTNode class, you may interact with these new nodes in a type-safe way. However, you will not be able to cast them to the node's specific type, as this requires the node to be defined at compile-time.

The current implementation recognises the following values:

  • Renderers
  • Renderer arrays
  • Text
  • Navigation endpoints
  • Author (does not currently detect the author thumbnails)
  • Thumbnails
  • Objects (key-value pairs)
  • Primatives (string, number, boolean, etc.)

This may be expanded in the future.

At runtime, these JIT-generated nodes will revalidate themselves when constructed so that when the types change, the node will be re-generated.

To access these nodes that have been generated at runtime, you may use the Parser.getParserByName(name: string) method. You may also check if a parser has been generated for a node by using the Parser.hasParser(name: string) method.

import { Parser } from "youtubei.js";

// We may check if we have a parser for a node.
if (Parser.hasParser('Example')) {
  // Then retrieve it.
  const Example = Parser.getParserByName('Example');
  // We may then use the parser as normal.
  const example = new Example(data);
}

You may also generate your own nodes ahead of time, given you have an example of one of the nodes.

import { Generator } from "youtubei.js";

// Provided you have an example of the node `Example`
const example_data = {
  "title": {
    "runs": [
      {
        "text": "Example"
      }
    ]
  }
}

// The first argument is the name of the class, the second is the data you have for the node.
// It will return a class that extends YTNode.
const Example = Generator.generateRuntimeClass('Example', example_data);

// You may now use this class as you would any other node.
const example = new Example(example_data);

const title = example.key('title').instanceof(Text).toString();

How it works

If you decompile a YouTube client and analyze it, it becomes apparent that it uses classes such as ../youtube/api/innertube/MusicItemRenderer and ../youtube/api/innertube/SectionListRenderer to parse objects from the response, map them into models, and generate the UI. The website operates similarly, but instead uses plain JSON. You can think of renderers as components in a web framework.

Our approach is similar to YouTube's: our parser goes through all the renderers and parses their inner element(s). The final result is a nicely structured JSON, it even parses navigation endpoints, which allow us to make an API call with all required parameters in one line and emulate client actions, such as clicking a button.

To illustrate the transformation we make, let's take an unstructured InnerTube response and parse it into a cleaner format:

Raw InnerTube Response:

{
  sidebar: {
    playlistSidebarRenderer: {
      items: [
        {
          playlistSidebarPrimaryInfoRenderer: {
            title: {
              simpleText: '..'
            },
            description: {
              runs: [
                {
                  text: '..'
                },
                //....
              ]
            },
            stats: [
              {
                simpleText: '..'
              },
              {
                runs: [
                  {
                    text: '..'
                  }
                ]
              }
            ]
          }
        }
      ]
    }
  }
}

Clean Parsed Response:

{
  sidebar: {
    type: 'PlaylistSidebar',
    contents: [
      {
        type: 'PlaylistSidebarPrimaryInfo',
        title: { text: '..', runs: [ { text: '..' } ] },
        description: { text: '..', runs: [ { text: '..' } ] },
        stats: [
          {
            text: '..',
            runs: [
              {
                text: '..'
              }
            ]
          },
          {
            text: '..',
            runs: [
              {
                text: '..'
              }
            ]
          }
        ]
      }
    ]
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/AboutChannel.ts:

```ts
import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import AboutChannelView from './AboutChannelView.js';
import Button from './Button.js';

export default class AboutChannel extends YTNode {
  static type = 'AboutChannel';

  metadata: AboutChannelView | null;
  share_channel: Button | null;

  constructor(data: RawNode) {
    super();

    this.metadata = Parser.parseItem(data.metadata, AboutChannelView);
    this.share_channel = Parser.parseItem(data.shareChannel, Button);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/AboutChannelView.ts:

import type { ObservedArray } from '../helpers.js';
import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import ChannelExternalLinkView from './ChannelExternalLinkView.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';

export default class AboutChannelView extends YTNode {
  static type = 'AboutChannelView';

  description?: string;
  description_label?: Text;
  country?: string;
  custom_links_label?: Text;
  subscriber_count?: string;
  view_count?: string;
  joined_date?: Text;
  canonical_channel_url?: string;
  channel_id?: string;
  additional_info_label?: Text;
  custom_url_on_tap?: NavigationEndpoint;
  video_count?: string;
  sign_in_for_business_email?: Text;
  links: ObservedArray<ChannelExternalLinkView>;

  constructor(data: RawNode) {
    super();

    if (Reflect.has(data, 'description')) {
      this.description = data.description;
    }

    if (Reflect.has(data, 'descriptionLabel')) {
      this.description_label = Text.fromAttributed(data.descriptionLabel);
    }

    if (Reflect.has(data, 'country')) {
      this.country = data.country;
    }

    if (Reflect.has(data, 'customLinksLabel')) {
      this.custom_links_label = Text.fromAttributed(data.customLinksLabel);
    }

    if (Reflect.has(data, 'subscriberCountText')) {
      this.subscriber_count = data.subscriberCountText;
    }

    if (Reflect.has(data, 'viewCountText')) {
      this.view_count = data.viewCountText;
    }

    if (Reflect.has(data, 'joinedDateText')) {
      this.joined_date = Text.fromAttributed(data.joinedDateText);
    }

    if (Reflect.has(data, 'canonicalChannelUrl')) {
      this.canonical_channel_url = data.canonicalChannelUrl;
    }

    if (Reflect.has(data, 'channelId')) {
      this.channel_id = data.channelId;
    }

    if (Reflect.has(data, 'additionalInfoLabel')) {
      this.additional_info_label = Text.fromAttributed(data.additionalInfoLabel);
    }

    if (Reflect.has(data, 'customUrlOnTap')) {
      this.custom_url_on_tap = new NavigationEndpoint(data.customUrlOnTap);
    }

    if (Reflect.has(data, 'videoCountText')) {
      this.video_count = data.videoCountText;
    }

    if (Reflect.has(data, 'signInForBusinessEmail')) {
      this.sign_in_for_business_email = Text.fromAttributed(data.signInForBusinessEmail);
    }

    if (Reflect.has(data, 'links')) {
      this.links = Parser.parseArray(data.links, ChannelExternalLinkView);
    } else {
      this.links = [] as unknown as ObservedArray<ChannelExternalLinkView>;
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/AccountChannel.ts:

import Text from './misc/Text.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class AccountChannel extends YTNode {
  static type = 'AccountChannel';

  title: Text;
  endpoint: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/AccountItemSection.ts:

import { Parser } from '../index.js';
import AccountItemSectionHeader from './AccountItemSectionHeader.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';

import { YTNode, observe, type ObservedArray } from '../helpers.js';
import type { RawNode } from '../index.js';

/**
 * Not a real renderer but we treat it as one to keep things organized.
 */
export class AccountItem extends YTNode {
  static type = 'AccountItem';

  account_name: Text;
  account_photo: Thumbnail[];
  is_selected: boolean;
  is_disabled: boolean;
  has_channel: boolean;
  endpoint: NavigationEndpoint;
  account_byline: Text;

  constructor(data: RawNode) {
    super();
    this.account_name = new Text(data.accountName);
    this.account_photo = Thumbnail.fromResponse(data.accountPhoto);
    this.is_selected = !!data.isSelected;
    this.is_disabled = !!data.isDisabled;
    this.has_channel = !!data.hasChannel;
    this.endpoint = new NavigationEndpoint(data.serviceEndpoint);
    this.account_byline = new Text(data.accountByline);
  }
}

export default class AccountItemSection extends YTNode {
  static type = 'AccountItemSection';

  contents: ObservedArray<AccountItem>;
  header: AccountItemSectionHeader | null;

  constructor(data: RawNode) {
    super();
    this.contents = observe<AccountItem>(data.contents.map((ac: RawNode) => new AccountItem(ac.accountItem)));
    this.header = Parser.parseItem(data.header, AccountItemSectionHeader);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/AccountItemSectionHeader.ts:

import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class AccountItemSectionHeader extends YTNode {
  static type = 'AccountItemSectionHeader';

  title: Text;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/AccountSectionList.ts:

import { Parser } from '../index.js';
import AccountChannel from './AccountChannel.js';
import AccountItemSection from './AccountItemSection.js';

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class AccountSectionList extends YTNode {
  static type = 'AccountSectionList';

  contents;
  footers;

  constructor(data: RawNode) {
    super();
    this.contents = Parser.parseItem(data.contents[0], AccountItemSection);
    this.footers = Parser.parseItem(data.footers[0], AccountChannel);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/Alert.ts:

import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class Alert extends YTNode {
  static type = 'Alert';

  text: Text;
  alert_type: string;

  constructor(data: RawNode) {
    super();
    this.text = new Text(data.text);
    this.alert_type = data.type;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/AlertWithButton.ts:

import Button from './Button.js';
import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';

export default class AlertWithButton extends YTNode {
  static type = 'AlertWithButton';

  text: Text;
  alert_type: string;
  dismiss_button: Button | null;

  constructor(data: RawNode) {
    super();
    this.text = new Text(data.text);
    this.alert_type = data.type;
    this.dismiss_button = Parser.parseItem(data.dismissButton, Button);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/AttributionView.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Text from './misc/Text.js';

export default class AttributionView extends YTNode {
  static type = 'AttributionView';

  text: Text;
  suffix: Text;

  constructor(data: RawNode) {
    super();

    this.text = Text.fromAttributed(data.text);
    this.suffix = Text.fromAttributed(data.suffix);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/AudioOnlyPlayability.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class AudioOnlyPlayability extends YTNode {
  static type = 'AudioOnlyPlayability';

  audio_only_availability: string;

  constructor (data: RawNode) {
    super();
    this.audio_only_availability = data.audioOnlyAvailability;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/AutomixPreviewVideo.ts:

import { YTNode } from '../helpers.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import type { RawNode } from '../index.js';

export default class AutomixPreviewVideo extends YTNode {
  static type = 'AutomixPreviewVideo';

  playlist_video?: { endpoint: NavigationEndpoint };

  constructor(data: RawNode) {
    super();
    if (data?.content?.automixPlaylistVideoRenderer?.navigationEndpoint) {
      this.playlist_video = {
        endpoint: new NavigationEndpoint(data.content.automixPlaylistVideoRenderer.navigationEndpoint)
      };
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/AvatarView.ts:

import { YTNode } from '../helpers.js';
import { type RawNode } from '../index.js';
import { Thumbnail } from '../misc.js';

export default class AvatarView extends YTNode {
  static type = 'AvatarView';

  image: Thumbnail[];
  image_processor: {
    border_image_processor: {
      circular: boolean
    }
  };
  avatar_image_size: string;

  constructor(data: RawNode) {
    super();
    this.image = Thumbnail.fromResponse(data.image);
    this.image_processor = {
      border_image_processor: {
        circular: data.image.processor.borderImageProcessor.circular
      }
    };
    this.avatar_image_size = data.avatarImageSize;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/BackstageImage.ts:

import Thumbnail from './misc/Thumbnail.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class BackstageImage extends YTNode {
  static type = 'BackstageImage';

  image: Thumbnail[];
  endpoint: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.image = Thumbnail.fromResponse(data.image);
    this.endpoint = new NavigationEndpoint(data.command);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/BackstagePost.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Button from './Button.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import CommentActionButtons from './comments/CommentActionButtons.js';
import Menu from './menus/Menu.js';
import Author from './misc/Author.js';
import Text from './misc/Text.js';

export default class BackstagePost extends YTNode {
  static type = 'BackstagePost';

  id: string;
  author: Author;
  content: Text;
  published: Text;
  poll_status?: string;
  vote_status?: string;
  vote_count?: Text;
  menu?: Menu | null;
  action_buttons?: CommentActionButtons | null;
  vote_button?: Button | null;
  surface: string;
  endpoint?: NavigationEndpoint;
  attachment;

  constructor(data: RawNode) {
    super();
    this.id = data.postId;

    this.author = new Author({
      ...data.authorText,
      navigationEndpoint: data.authorEndpoint
    }, null, data.authorThumbnail);

    this.content = new Text(data.contentText);
    this.published = new Text(data.publishedTimeText);

    if (Reflect.has(data, 'pollStatus')) {
      this.poll_status = data.pollStatus;
    }

    if (Reflect.has(data, 'voteStatus')) {
      this.vote_status = data.voteStatus;
    }

    if (Reflect.has(data, 'voteCount')) {
      this.vote_count = new Text(data.voteCount);
    }

    if (Reflect.has(data, 'actionMenu')) {
      this.menu = Parser.parseItem(data.actionMenu, Menu);
    }

    if (Reflect.has(data, 'actionButtons')) {
      this.action_buttons = Parser.parseItem(data.actionButtons, CommentActionButtons);
    }

    if (Reflect.has(data, 'voteButton')) {
      this.vote_button = Parser.parseItem(data.voteButton, Button);
    }

    if (Reflect.has(data, 'navigationEndpoint')) {
      this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    }

    if (Reflect.has(data, 'backstageAttachment')) {
      this.attachment = Parser.parseItem(data.backstageAttachment);
    }

    this.surface = data.surface;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/BackstagePostThread.ts:

import { Parser, type RawNode } from '../index.js';
import { YTNode } from '../helpers.js';

export default class BackstagePostThread extends YTNode {
  static type = 'BackstagePostThread';

  post: YTNode;

  constructor(data: RawNode) {
    super();
    this.post = Parser.parseItem(data.post);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/BrowseFeedActions.ts:

import { Parser, type RawNode } from '../index.js';
import { type ObservedArray, YTNode } from '../helpers.js';

export default class BrowseFeedActions extends YTNode {
  static type = 'BrowseFeedActions';

  contents: ObservedArray<YTNode>;

  constructor(data: RawNode) {
    super();
    this.contents = Parser.parseArray(data.contents);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/BrowserMediaSession.ts:

import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class BrowserMediaSession extends YTNode {
  static type = 'BrowserMediaSession';

  album: Text;
  thumbnails: Thumbnail[];

  constructor (data: RawNode) {
    super();
    this.album = new Text(data.album);
    this.thumbnails = Thumbnail.fromResponse(data.thumbnailDetails);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/Button.ts:

import Text from './misc/Text.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class Button extends YTNode {
  static type = 'Button';

  text?: string;
  label?: string;
  tooltip?: string;
  icon_type?: string;
  is_disabled?: boolean;
  endpoint: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    if (Reflect.has(data, 'text'))
      this.text = new Text(data.text).toString();

    if (Reflect.has(data, 'accessibility') && Reflect.has(data.accessibility, 'label'))
      this.label = data.accessibility.label;

    if (Reflect.has(data, 'tooltip'))
      this.tooltip = data.tooltip;

    if (Reflect.has(data, 'icon') && Reflect.has(data.icon, 'iconType'))
      this.icon_type = data.icon.iconType;

    if (Reflect.has(data, 'isDisabled'))
      this.is_disabled = data.isDisabled;

    this.endpoint = new NavigationEndpoint(data.navigationEndpoint || data.serviceEndpoint || data.command);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ButtonView.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.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;

  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);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/C4TabbedHeader.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Button from './Button.js';
import ChannelHeaderLinks from './ChannelHeaderLinks.js';
import ChannelHeaderLinksView from './ChannelHeaderLinksView.js';
import ChannelTagline from './ChannelTagline.js';
import SubscribeButton from './SubscribeButton.js';
import Author from './misc/Author.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';

export default class C4TabbedHeader extends YTNode {
  static type = 'C4TabbedHeader';

  author: Author;
  banner?: Thumbnail[];
  tv_banner?: Thumbnail[];
  mobile_banner?: Thumbnail[];
  subscribers?: Text;
  videos_count?: Text;
  sponsor_button?: Button | null;
  subscribe_button?: SubscribeButton | Button | null;
  header_links?: ChannelHeaderLinks | ChannelHeaderLinksView | null;
  channel_handle?: Text;
  channel_id?: string;
  tagline?: ChannelTagline | null;

  constructor(data: RawNode) {
    super();
    this.author = new Author({
      simpleText: data.title,
      navigationEndpoint: data.navigationEndpoint
    }, data.badges, data.avatar);

    if (Reflect.has(data, 'banner')) {
      this.banner = Thumbnail.fromResponse(data.banner);
    }

    if (Reflect.has(data, 'tv_banner')) {
      this.tv_banner = Thumbnail.fromResponse(data.tvBanner);
    }

    if (Reflect.has(data, 'mobile_banner')) {
      this.mobile_banner = Thumbnail.fromResponse(data.mobileBanner);
    }

    if (Reflect.has(data, 'subscriberCountText')) {
      this.subscribers = new Text(data.subscriberCountText);
    }

    if (Reflect.has(data, 'videosCountText')) {
      this.videos_count = new Text(data.videosCountText);
    }

    if (Reflect.has(data, 'sponsorButton')) {
      this.sponsor_button = Parser.parseItem(data.sponsorButton, Button);
    }

    if (Reflect.has(data, 'subscribeButton')) {
      this.subscribe_button = Parser.parseItem(data.subscribeButton, [ SubscribeButton, Button ]);
    }

    if (Reflect.has(data, 'headerLinks')) {
      this.header_links = Parser.parseItem(data.headerLinks, [ ChannelHeaderLinks, ChannelHeaderLinksView ]);
    }

    if (Reflect.has(data, 'channelHandleText')) {
      this.channel_handle = new Text(data.channelHandleText);
    }

    if (Reflect.has(data, 'channelId')) {
      this.channel_id = data.channelId;
    }

    if (Reflect.has(data, 'tagline')) {
      this.tagline = Parser.parseItem(data.tagline, ChannelTagline);
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/CallToActionButton.ts:

import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class CallToActionButton extends YTNode {
  static type = 'CallToActionButton';

  label: Text;
  icon_type: string;
  style: string;

  constructor(data: RawNode) {
    super();
    this.label = new Text(data.label);
    this.icon_type = data.icon.iconType;
    this.style = data.style;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/Card.ts:

import { Parser, type RawNode } from '../index.js';
import { YTNode } from '../helpers.js';

export default class Card extends YTNode {
  static type = 'Card';

  teaser: YTNode;
  content: YTNode;
  card_id?: string;
  feature?: string;

  cue_ranges: {
    start_card_active_ms: string;
    end_card_active_ms: string;
    teaser_duration_ms: string;
    icon_after_teaser_ms: string;
  }[];

  constructor(data: RawNode) {
    super();
    this.teaser = Parser.parseItem(data.teaser);
    this.content = Parser.parseItem(data.content);

    if (Reflect.has(data, 'cardId')) {
      this.card_id = data.cardId;
    }

    if (Reflect.has(data, 'feature')) {
      this.feature = data.feature;
    }

    this.cue_ranges = data.cueRanges.map((cr: any) => ({
      start_card_active_ms: cr.startCardActiveMs,
      end_card_active_ms: cr.endCardActiveMs,
      teaser_duration_ms: cr.teaserDurationMs,
      icon_after_teaser_ms: cr.iconAfterTeaserMs
    }));
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/CardCollection.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Text from './misc/Text.js';

export default class CardCollection extends YTNode {
  static type = 'CardCollection';

  cards: ObservedArray<YTNode>;
  header: Text;
  allow_teaser_dismiss: boolean;

  constructor(data: RawNode) {
    super();
    this.cards = Parser.parseArray(data.cards);
    this.header = new Text(data.headerText);
    this.allow_teaser_dismiss = data.allowTeaserDismiss;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/CarouselHeader.ts:

import { Parser, type RawNode } from '../index.js';
import { type ObservedArray, YTNode } from '../helpers.js';

export default class CarouselHeader extends YTNode {
  static type = 'CarouselHeader';

  contents: ObservedArray<YTNode>;

  constructor(data: RawNode) {
    super();
    this.contents = Parser.parseArray(data.contents);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/CarouselItem.ts:

import { Parser, type RawNode } from '../index.js';
import { type ObservedArray, YTNode } from '../helpers.js';
import Thumbnail from './misc/Thumbnail.js';

export default class CarouselItem extends YTNode {
  static type = 'CarouselItem';

  items: ObservedArray<YTNode>;
  background_color: string;
  layout_style: string;
  pagination_thumbnails: Thumbnail[];
  paginator_alignment: string;

  constructor (data: RawNode) {
    super();
    this.items = Parser.parseArray(data.carouselItems);
    this.background_color = data.backgroundColor;
    this.layout_style = data.layoutStyle;
    this.pagination_thumbnails = Thumbnail.fromResponse(data.paginationThumbnails);
    this.paginator_alignment = data.paginatorAlignment;
  }

  // XXX: For consistency.
  get contents() {
    return this.items;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/CarouselLockup.ts:

import { type ObservedArray, YTNode } from '../helpers.js';
import InfoRow from './InfoRow.js';
import { Parser, type RawNode } from '../index.js';
import CompactVideo from './CompactVideo.js';

export default class CarouselLockup extends YTNode {
  static type = 'CarouselLockup';

  info_rows: ObservedArray<InfoRow>;
  video_lockup?: CompactVideo | null;

  constructor(data: RawNode) {
    super();
    this.info_rows = Parser.parseArray(data.infoRows, InfoRow);
    this.video_lockup = Parser.parseItem(data.videoLockup, CompactVideo);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/Channel.ts:

import { Log } from '../../utils/index.js';
import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Button from './Button.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import SubscribeButton from './SubscribeButton.js';
import Author from './misc/Author.js';
import Text from './misc/Text.js';

export default class Channel extends YTNode {
  static type = 'Channel';

  id: string;
  author: Author;
  subscriber_count: Text;
  video_count: Text;
  long_byline: Text;
  short_byline: Text;
  endpoint: NavigationEndpoint;
  subscribe_button: SubscribeButton | Button | null;
  description_snippet: Text;

  constructor(data: RawNode) {
    super();
    this.id = data.channelId;

    this.author = new Author({
      ...data.title,
      navigationEndpoint: data.navigationEndpoint
    }, data.ownerBadges, data.thumbnail);

    // XXX: `subscriberCountText` is now the channel's handle and `videoCountText` is the subscriber count.
    this.subscriber_count = new Text(data.subscriberCountText);
    this.video_count = new Text(data.videoCountText);
    this.long_byline = new Text(data.longBylineText);
    this.short_byline = new Text(data.shortBylineText);
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    this.subscribe_button = Parser.parseItem(data.subscribeButton, [ SubscribeButton, Button ]);
    this.description_snippet = new Text(data.descriptionSnippet);
  }

  /**
   * @deprecated
   * This will be removed in a future release.
   * Please use {@link Channel.subscriber_count} instead.
   */
  get subscribers(): Text {
    Log.warnOnce(Channel.type, 'Channel#subscribers is deprecated. Please use Channel#subscriber_count instead.');
    return this.subscriber_count;
  }

  /**
   * @deprecated
   * This will be removed in a future release.
   * Please use {@link Channel.video_count} instead.
   */
  get videos(): Text {
    Log.warnOnce(Channel.type, 'Channel#videos is deprecated. Please use Channel#video_count instead.');
    return this.video_count;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ChannelAboutFullMetadata.ts:

import { Log } from '../../utils/index.js';
import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Button from './Button.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';

export default class ChannelAboutFullMetadata extends YTNode {
  static type = 'ChannelAboutFullMetadata';

  id: string;
  name: Text;
  avatar: Thumbnail[];
  canonical_channel_url: string;

  primary_links: {
    endpoint: NavigationEndpoint;
    icon: Thumbnail[];
    title: Text;
  }[];

  view_count: Text;
  joined_date: Text;
  description: Text;
  email_reveal: NavigationEndpoint;
  can_reveal_email: boolean;
  country: Text;
  buttons: ObservedArray<Button>;

  constructor(data: RawNode) {
    super();
    this.id = data.channelId;
    this.name = new Text(data.title);
    this.avatar = Thumbnail.fromResponse(data.avatar);
    this.canonical_channel_url = data.canonicalChannelUrl;

    this.primary_links = data.primaryLinks?.map((link: any) => ({
      endpoint: new NavigationEndpoint(link.navigationEndpoint),
      icon: Thumbnail.fromResponse(link.icon),
      title: new Text(link.title)
    })) ?? [];

    this.view_count = new Text(data.viewCountText);
    this.joined_date = new Text(data.joinedDateText);
    this.description = new Text(data.description);
    this.email_reveal = new NavigationEndpoint(data.onBusinessEmailRevealClickCommand);
    this.can_reveal_email = !data.signInForBusinessEmail;
    this.country = new Text(data.country);
    this.buttons = Parser.parseArray(data.actionButtons, Button);
  }

  /**
   * @deprecated
   * This will be removed in a future release.
   * Please use {@link Channel.view_count} instead.
   */
  get views() {
    Log.warnOnce(ChannelAboutFullMetadata.type, 'ChannelAboutFullMetadata#views is deprecated. Please use ChannelAboutFullMetadata#view_count instead.');
    return this.view_count;
  }

  /**
   * @deprecated
   * This will be removed in a future release.
   * Please use {@link Channel.joined_date} instead.
   */
  get joined(): Text {
    Log.warnOnce(ChannelAboutFullMetadata.type, 'ChannelAboutFullMetadata#joined is deprecated. Please use ChannelAboutFullMetadata#joined_date instead.');
    return this.joined_date;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ChannelAgeGate.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import { Parser } from '../index.js';
import Button from './Button.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';

export default class ChannelAgeGate extends YTNode {
  static type = 'ChannelAgeGate';

  channel_title: string;
  avatar: Thumbnail[];
  header: Text;
  main_text: Text;
  sign_in_button: Button | null;
  secondary_text: Text;

  constructor(data: RawNode) {
    super();
    this.channel_title = data.channelTitle;
    this.avatar = Thumbnail.fromResponse(data.avatar);
    this.header = new Text(data.header);
    this.main_text = new Text(data.mainText);
    this.sign_in_button = Parser.parseItem(data.signInButton, Button);
    this.secondary_text = new Text(data.secondaryText);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ChannelExternalLinkView.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';

export default class ChannelExternalLinkView extends YTNode {
  static type = 'ChannelExternalLinkView';

  title: Text;
  link: Text;
  favicon: Thumbnail[];

  constructor(data: RawNode) {
    super();

    this.title = Text.fromAttributed(data.title);
    this.link = Text.fromAttributed(data.link);
    this.favicon = Thumbnail.fromResponse(data.favicon);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ChannelFeaturedContent.ts:

import { type ObservedArray, YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Text from './misc/Text.js';

export default class ChannelFeaturedContent extends YTNode {
  static type = 'ChannelFeaturedContent';

  title: Text;
  items: ObservedArray<YTNode>;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.items = Parser.parseArray(data.items);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ChannelHeaderLinks.ts:

import { YTNode, observe, type ObservedArray } from '../helpers.js';
import type { RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';

// XXX (LuanRT): This is not a real YTNode, but we treat it as one to keep things clean.
export class HeaderLink extends YTNode {
  static type = 'HeaderLink';

  endpoint: NavigationEndpoint;
  icon: Thumbnail[];
  title: Text;

  constructor(data: RawNode) {
    super();
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    this.icon = Thumbnail.fromResponse(data.icon);
    this.title = new Text(data.title);
  }
}

export default class ChannelHeaderLinks extends YTNode {
  static type = 'ChannelHeaderLinks';

  primary: ObservedArray<HeaderLink>;
  secondary: ObservedArray<HeaderLink>;

  constructor(data: RawNode) {
    super();
    this.primary = observe(data.primaryLinks?.map((link: RawNode) => new HeaderLink(link)) || []);
    this.secondary = observe(data.secondaryLinks?.map((link: RawNode) => new HeaderLink(link)) || []);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ChannelHeaderLinksView.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Text from './misc/Text.js';

export default class ChannelHeaderLinksView extends YTNode {
  static type = 'ChannelHeaderLinksView';

  first_link?: Text;
  more?: Text;

  constructor(data: RawNode) {
    super();

    if (Reflect.has(data, 'firstLink')) {
      this.first_link = Text.fromAttributed(data.firstLink);
    }

    if (Reflect.has(data, 'more')) {
      this.more = Text.fromAttributed(data.more);
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ChannelMetadata.ts:

import Thumbnail from './misc/Thumbnail.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class ChannelMetadata extends YTNode {
  static type = 'ChannelMetadata';

  title: string;
  description: string;
  url: string;
  rss_url: string;
  vanity_channel_url: string;
  external_id: string;
  is_family_safe: boolean;
  keywords: string[];
  avatar: Thumbnail[];
  music_artist_name?: string;
  available_countries: string[];
  android_deep_link: string;
  android_appindexing_link: string;
  ios_appindexing_link: string;

  constructor(data: RawNode) {
    super();
    this.title = data.title;
    this.description = data.description;
    this.url = data.channelUrl;
    this.rss_url = data.rssUrl;
    this.vanity_channel_url = data.vanityChannelUrl;
    this.external_id = data.externalId;
    this.is_family_safe = data.isFamilySafe;
    this.keywords = data.keywords;
    this.avatar = Thumbnail.fromResponse(data.avatar);
    // Can be an empty string sometimes, so we need the extra length check
    this.music_artist_name = typeof data.musicArtistName === 'string' && data.musicArtistName.length > 0 ? data.musicArtistName : undefined;
    this.available_countries = data.availableCountryCodes;
    this.android_deep_link = data.androidDeepLink;
    this.android_appindexing_link = data.androidAppindexingLink;
    this.ios_appindexing_link = data.iosAppindexingLink;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ChannelMobileHeader.ts:

import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class ChannelMobileHeader extends YTNode {
  static type = 'ChannelMobileHeader';

  title: Text;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ChannelOptions.ts:

import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class ChannelOptions extends YTNode {
  static type = 'ChannelOptions';

  avatar: Thumbnail[];
  endpoint: NavigationEndpoint;
  name: string;
  links: Text[];

  constructor(data: RawNode) {
    super();
    this.avatar = Thumbnail.fromResponse(data.avatar);
    this.endpoint = new NavigationEndpoint(data.avatarEndpoint);
    this.name = data.name;
    this.links = data.links.map((link: RawNode) => new Text(link));
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ChannelOwnerEmptyState.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';

export default class ChannelOwnerEmptyState extends YTNode {
  static type = 'ChannelOwnerEmptyState';

  illustration: Thumbnail[];
  description: Text;

  constructor(data: RawNode) {
    super();
    this.illustration = Thumbnail.fromResponse(data.illustration);
    this.description = new Text(data.description);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ChannelSubMenu.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';

export default class ChannelSubMenu extends YTNode {
  static type = 'ChannelSubMenu';

  content_type_sub_menu_items: {
    endpoint: NavigationEndpoint;
    selected: boolean;
    title: string;
  }[];

  sort_setting;

  constructor(data: RawNode) {
    super();
    this.content_type_sub_menu_items = data.contentTypeSubMenuItems.map((item: RawNode) => ({
      endpoint: new NavigationEndpoint(item.navigationEndpoint || item.endpoint),
      selected: item.selected,
      title: item.title
    }));
    this.sort_setting = Parser.parseItem(data.sortSetting);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ChannelTagline.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import EngagementPanelSectionList from './EngagementPanelSectionList.js';

export default class ChannelTagline extends YTNode {
  static type = 'ChannelTagline';

  content: string;
  max_lines: number;
  more_endpoint: {
    show_engagement_panel_endpoint: {
      engagement_panel: EngagementPanelSectionList | null,
      engagement_panel_popup_type: string;
      identifier: {
        surface: string,
        tag: string
      }
    }
  } | NavigationEndpoint;
  more_icon_type: string;
  more_label: string;
  target_id: string;

  constructor(data: RawNode) {
    super();

    this.content = data.content;
    this.max_lines = data.maxLines;
    this.more_endpoint = data.moreEndpoint.showEngagementPanelEndpoint ? {
      show_engagement_panel_endpoint: {
        engagement_panel: Parser.parseItem(data.moreEndpoint.showEngagementPanelEndpoint.engagementPanel, EngagementPanelSectionList),
        engagement_panel_popup_type: data.moreEndpoint.showEngagementPanelEndpoint.engagementPanelPresentationConfigs.engagementPanelPopupPresentationConfig.popupType,
        identifier: {
          surface: data.moreEndpoint.showEngagementPanelEndpoint.identifier.surface,
          tag: data.moreEndpoint.showEngagementPanelEndpoint.identifier.tag
        }
      }
    } : new NavigationEndpoint(data.moreEndpoint);
    this.more_icon_type = data.moreIcon.iconType;
    this.more_label = data.moreLabel;
    this.target_id = data.targetId;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ChannelThumbnailWithLink.ts:

import Thumbnail from './misc/Thumbnail.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class ChannelThumbnailWithLink extends YTNode {
  static type = 'ChannelThumbnailWithLink';

  thumbnails: Thumbnail[];
  endpoint: NavigationEndpoint;
  label?: string;

  constructor(data: RawNode) {
    super();
    this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    this.label = data.accessibility?.accessibilityData?.label;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ChannelVideoPlayer.ts:

import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import { Log } from '../../utils/index.js';

export default class ChannelVideoPlayer extends YTNode {
  static type = 'ChannelVideoPlayer';

  id: string;
  title: Text;
  description: Text;
  view_count: Text;
  published_time: Text;

  constructor(data: RawNode) {
    super();
    this.id = data.videoId;
    this.title = new Text(data.title);
    this.description = new Text(data.description);
    this.view_count = new Text(data.viewCountText);
    this.published_time = new Text(data.publishedTimeText);
  }

  /**
   * @deprecated
   * This will be removed in a future release.
   * Please use {@link ChannelVideoPlayer.view_count} instead.
   */
  get views(): Text {
    Log.warnOnce(ChannelVideoPlayer.type, 'ChannelVideoPlayer#views is deprecated. Please use ChannelVideoPlayer#view_count instead.');
    return this.view_count;
  }

  /**
   * @deprecated
   * This will be removed in a future release.
   * Please use {@link ChannelVideoPlayer.published_time} instead.
   */
  get published(): Text {
    Log.warnOnce(ChannelVideoPlayer.type, 'ChannelVideoPlayer#published is deprecated. Please use ChannelVideoPlayer#published_time instead.');
    return this.published_time;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/Chapter.ts:

import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class Chapter extends YTNode {
  static type = 'Chapter';

  title: Text;
  time_range_start_millis: number;
  thumbnail: Thumbnail[];

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.time_range_start_millis = data.timeRangeStartMillis;
    this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ChildVideo.ts:

import { timeToSeconds } from '../../utils/Utils.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';

export default class ChildVideo extends YTNode {
  static type = 'ChildVideo';

  id: string;
  title: Text;

  duration: {
    text: string;
    seconds: number;
  };

  endpoint: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.id = data.videoId;
    this.title = new Text(data.title);
    this.duration = {
      text: data.lengthText.simpleText,
      seconds: timeToSeconds(data.lengthText.simpleText)
    };
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ChipBarView.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import ChipView from './ChipView.js';

export default class ChipBarView extends YTNode {
  static type = 'ChipBarView';

  chips: ObservedArray<ChipView> | null;

  constructor(data: RawNode) {
    super();
    this.chips = Parser.parseArray(data.chips, ChipView);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ChipCloud.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Button from './Button.js';
import ChipCloudChip from './ChipCloudChip.js';

export default class ChipCloud extends YTNode {
  static type = 'ChipCloud';

  chips: ObservedArray<ChipCloudChip>;
  next_button: Button | null;
  previous_button: Button | null;
  horizontal_scrollable: boolean;

  constructor(data: RawNode) {
    super();
    this.chips = Parser.parseArray(data.chips, ChipCloudChip);
    this.next_button = Parser.parseItem(data.nextButton, Button);
    this.previous_button = Parser.parseItem(data.previousButton, Button);
    this.horizontal_scrollable = data.horizontalScrollable;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ChipCloudChip.ts:

import Text from './misc/Text.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class ChipCloudChip extends YTNode {
  static type = 'ChipCloudChip';

  is_selected: boolean;
  endpoint?: NavigationEndpoint;
  text: string;

  constructor(data: RawNode) {
    super();
    this.is_selected = data.isSelected;
    if (Reflect.has(data, 'navigationEndpoint')) {
      this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    }
    this.text = new Text(data.text).toString();
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ChipView.ts:

import { YTNode } from '../helpers.js';
import { type RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';

export default class ChipView extends YTNode {
  static type = 'ChipView';

  text: string;
  display_type: string;
  endpoint: NavigationEndpoint;
  chip_entity_key: string;

  constructor(data: RawNode) {
    super();
    this.text = data.text;
    this.display_type = data.displayType;
    this.endpoint = new NavigationEndpoint(data.tapCommand);
    this.chip_entity_key = data.chipEntityKey;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ClipAdState.ts:

import { YTNode } from '../helpers.js';
import Text from './misc/Text.js';

import type { RawNode } from '../types/index.js';

export default class ClipAdState extends YTNode {
  static type = 'ClipAdState';

  title: Text;
  body: Text;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.body = new Text(data.body);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ClipCreation.ts:

import { YTNode } from '../helpers.js';
import Thumbnail from './misc/Thumbnail.js';
import Button from './Button.js';
import ClipCreationTextInput from './ClipCreationTextInput.js';
import ClipCreationScrubber from './ClipCreationScrubber.js';
import ClipAdState from './ClipAdState.js';
import Text from './misc/Text.js';

import { Parser } from '../index.js';

import type { RawNode } from '../types/index.js';

export default class ClipCreation extends YTNode {
  static type = 'ClipCreation';

  user_avatar: Thumbnail[];
  title_input: ClipCreationTextInput | null;
  scrubber: ClipCreationScrubber | null;
  save_button: Button | null;
  display_name: Text;
  publicity_label: string;
  cancel_button: Button | null;
  ad_state_overlay: ClipAdState | null;
  external_video_id: string;
  publicity_label_icon: string;

  constructor(data: RawNode) {
    super();
    this.user_avatar = Thumbnail.fromResponse(data.userAvatar);
    this.title_input = Parser.parseItem(data.titleInput, [ ClipCreationTextInput ]);
    this.scrubber = Parser.parseItem(data.scrubber, [ ClipCreationScrubber ]);
    this.save_button = Parser.parseItem(data.saveButton, [ Button ]);
    this.display_name = new Text(data.displayName);
    this.publicity_label = data.publicityLabel;
    this.cancel_button = Parser.parseItem(data.cancelButton, [ Button ]);
    this.ad_state_overlay = Parser.parseItem(data.adStateOverlay, [ ClipAdState ]);
    this.external_video_id = data.externalVideoId;
    this.publicity_label_icon = data.publicityLabelIcon;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ClipCreationScrubber.ts:

import { YTNode } from '../helpers.js';

import type { RawNode } from '../types/index.js';

export default class ClipCreationScrubber extends YTNode {
  static type = 'ClipCreationScrubber';

  length_template: string;
  max_length_ms: number;
  min_length_ms: number;
  default_length_ms: number;
  window_size_ms: number;
  start_label?: string;
  end_label?: string;
  duration_label?: string;

  constructor(data: RawNode) {
    super();
    this.length_template = data.lengthTemplate;
    this.max_length_ms = data.maxLengthMs;
    this.min_length_ms = data.minLengthMs;
    this.default_length_ms = data.defaultLengthMs;
    this.window_size_ms = data.windowSizeMs;
    this.start_label = data.startAccessibility?.accessibilityData?.label;
    this.end_label = data.endAccessibility?.accessibilityData?.label;
    this.duration_label = data.durationAccessibility?.accessibilityData?.label;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ClipCreationTextInput.ts:

import { YTNode } from '../helpers.js';
import Text from './misc/Text.js';

import type { RawNode } from '../types/index.js';

export default class ClipCreationTextInput extends YTNode {
  static type = 'ClipCreationTextInput';

  placeholder_text: Text;
  max_character_limit: number;

  constructor(data: RawNode) {
    super();
    this.placeholder_text = new Text(data.placeholderText);
    this.max_character_limit = data.maxCharacterLimit;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ClipSection.ts:

import type { ObservedArray } from '../helpers.js';
import { YTNode } from '../helpers.js';

import ClipCreation from './ClipCreation.js';

import { Parser } from '../index.js';

import type { RawNode } from '../types/index.js';

export default class ClipSection extends YTNode {
  static type = 'ClipSection';

  contents: ObservedArray<ClipCreation> | null;

  constructor(data: RawNode) {
    super();
    this.contents = Parser.parse(data.contents, true, [ ClipCreation ]);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/CollaboratorInfoCardContent.ts:

import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class CollaboratorInfoCardContent extends YTNode {
  static type = 'CollaboratorInfoCardContent';

  channel_avatar: Thumbnail[];
  custom_text: Text;
  channel_name: Text;
  subscriber_count: Text;
  endpoint: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.channel_avatar = Thumbnail.fromResponse(data.channelAvatar);
    this.custom_text = new Text(data.customText);
    this.channel_name = new Text(data.channelName);
    this.subscriber_count = new Text(data.subscriberCountText);
    this.endpoint = new NavigationEndpoint(data.endpoint);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/CollageHeroImage.ts:

import NavigationEndpoint from './NavigationEndpoint.js';
import Thumbnail from './misc/Thumbnail.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class CollageHeroImage extends YTNode {
  static type = 'CollageHeroImage';

  left: Thumbnail[];
  top_right: Thumbnail[];
  bottom_right: Thumbnail[];
  endpoint: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.left = Thumbnail.fromResponse(data.leftThumbnail);
    this.top_right = Thumbnail.fromResponse(data.topRightThumbnail);
    this.bottom_right = Thumbnail.fromResponse(data.bottomRightThumbnail);
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/CollectionThumbnailView.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import ThumbnailView from './ThumbnailView.js';

export default class CollectionThumbnailView extends YTNode {
  static type = 'CollectionThumbnailView';

  primary_thumbnail: ThumbnailView | null;
  stack_color?: {
    light_theme: number;
    dark_theme: number;
  };

  constructor(data: RawNode) {
    super();

    this.primary_thumbnail = Parser.parseItem(data.primaryThumbnail, ThumbnailView);
    if (data.stackColor) {
      this.stack_color = {
        light_theme: data.stackColor.lightTheme,
        dark_theme: data.stackColor.darkTheme
      };
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/CompactChannel.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Menu from './menus/Menu.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';

export default class CompactChannel extends YTNode {
  static type = 'CompactChannel';

  title: Text;
  channel_id: string;
  thumbnail: Thumbnail[];
  display_name: Text;
  video_count: Text;
  subscriber_count: Text;
  endpoint: NavigationEndpoint;
  tv_banner: Thumbnail[];
  menu: Menu | null;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.channel_id = data.channelId;
    this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
    this.display_name = new Text(data.displayName);
    this.video_count = new Text(data.videoCountText);
    this.subscriber_count = new Text(data.subscriberCountText);
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    this.tv_banner = Thumbnail.fromResponse(data.tvBanner);
    this.menu = Parser.parseItem(data.menu, Menu);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/CompactLink.ts:

import Text from './misc/Text.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class CompactLink extends YTNode {
  static type = 'CompactLink';

  title: string;
  endpoint: NavigationEndpoint;
  style: string;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title).toString();
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    this.style = data.style;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/CompactMix.ts:

import type { RawNode } from '../index.js';
import Playlist from './Playlist.js';

export default class CompactMix extends Playlist {
  static type = 'CompactMix';

  constructor(data: RawNode) {
    super(data);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/CompactMovie.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import type { RawNode } from '../index.js';
import { Parser } from '../index.js';
import Author from './misc/Author.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import Menu from './menus/Menu.js';
import { timeToSeconds } from '../../utils/Utils.js';

export default class CompactMovie extends YTNode {
  static type = 'CompactMovie';

  id: string;
  title: Text;
  top_metadata_items: Text;
  thumbnails: Thumbnail[];
  thumbnail_overlays: ObservedArray<YTNode>;
  author: Author;

  duration: {
    text: string;
    seconds: number;
  };

  endpoint: NavigationEndpoint;
  badges: ObservedArray<YTNode>;
  use_vertical_poster: boolean;
  menu: Menu | null;

  constructor(data: RawNode) {
    super();
    const overlay_time_status = data.thumbnailOverlays
      .find((overlay: RawNode) => overlay.thumbnailOverlayTimeStatusRenderer)
      ?.thumbnailOverlayTimeStatusRenderer.text || 'N/A';

    this.id = data.videoId;
    this.title = new Text(data.title);

    this.top_metadata_items = new Text(data.topMetadataItems);
    this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
    this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);
    this.author = new Author(data.shortBylineText);

    const durationText = data.lengthText ? new Text(data.lengthText).toString() : new Text(overlay_time_status).toString();

    this.duration = {
      text: durationText,
      seconds: timeToSeconds(durationText)
    };

    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    this.badges = Parser.parseArray(data.badges);
    this.use_vertical_poster = data.useVerticalPoster;
    this.menu = Parser.parseItem(data.menu, Menu);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/CompactPlaylist.ts:

import type { RawNode } from '../index.js';
import Playlist from './Playlist.js';

class CompactPlaylist extends Playlist {
  static type = 'CompactPlaylist';

  constructor(data: RawNode) {
    super(data);
  }
}

export default CompactPlaylist;

LuanRT/YouTube.js/blob/main/src/parser/classes/CompactStation.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import NavigationEndpoint from './NavigationEndpoint.js';

export default class CompactStation extends YTNode {
  static type = 'CompactStation';

  title: Text;
  description: Text;
  video_count: Text;
  endpoint: NavigationEndpoint;
  thumbnail: Thumbnail[];

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.description = new Text(data.description);
    this.video_count = new Text(data.videoCountText);
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/CompactVideo.ts:

import { timeToSeconds } from '../../utils/Utils.js';
import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Menu from './menus/Menu.js';
import MetadataBadge from './MetadataBadge.js';
import Author from './misc/Author.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import NavigationEndpoint from './NavigationEndpoint.js';

export default class CompactVideo extends YTNode {
  static type = 'CompactVideo';

  id: string;
  thumbnails: Thumbnail[];
  rich_thumbnail?: YTNode;
  title: Text;
  author: Author;
  view_count: Text;
  short_view_count: Text;
  published: Text;
  badges: MetadataBadge[];

  duration: {
    text: string;
    seconds: number;
  };

  thumbnail_overlays: ObservedArray<YTNode>;
  endpoint: NavigationEndpoint;
  menu: Menu | null;

  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.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.badges = Parser.parseArray(data.badges, MetadataBadge);

    this.duration = {
      text: new Text(data.lengthText).toString(),
      seconds: timeToSeconds(new Text(data.lengthText).toString())
    };

    this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    this.menu = Parser.parseItem(data.menu, Menu);
  }

  get best_thumbnail() {
    return this.thumbnails[0];
  }

  get is_fundraiser(): boolean {
    return this.badges.some((badge) => badge.label === 'Fundraiser');
  }

  get is_live(): boolean {
    return this.badges.some((badge) => {
      if (badge.style === 'BADGE_STYLE_TYPE_LIVE_NOW' || badge.label === 'LIVE')
        return true;
    });
  }

  get is_new(): boolean {
    return this.badges.some((badge) => badge.label === 'New');
  }

  get is_premiere(): boolean {
    return this.badges.some((badge) => badge.style === 'PREMIERE');
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ConfirmDialog.ts:

import { Parser, type RawNode } from '../index.js';
import Text from './misc/Text.js';
import Button from './Button.js';
import { YTNode } from '../helpers.js';

export default class ConfirmDialog extends YTNode {
  static type = 'ConfirmDialog';

  title: Text;
  confirm_button: Button | null;
  cancel_button: Button | null;
  dialog_messages: Text[];

  constructor (data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.confirm_button = Parser.parseItem(data.confirmButton, Button);
    this.cancel_button = Parser.parseItem(data.cancelButton, Button);
    this.dialog_messages = data.dialogMessages.map((txt: RawNode) => new Text(txt));
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ContentMetadataView.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import { Text } from '../misc.js';

export type MetadataRow = {
  metadata_parts?: {
    text: Text;
  }[];
};

export default class ContentMetadataView extends YTNode {
  static type = 'ContentMetadataView';

  metadata_rows: MetadataRow[];
  delimiter: string;

  constructor(data: RawNode) {
    super();
    this.metadata_rows = data.metadataRows.map((row: RawNode) => ({
      metadata_parts: row.metadataParts?.map((part: RawNode) => ({
        text: Text.fromAttributed(part.text)
      }))
    }));
    this.delimiter = data.delimiter;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ContentPreviewImageView.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Thumbnail from './misc/Thumbnail.js';

export default class ContentPreviewImageView extends YTNode {
  static type = 'ContentPreviewImageView';

  image: Thumbnail[];
  style: string;

  constructor(data: RawNode) {
    super();
    this.image = Thumbnail.fromResponse(data.image);
    this.style = data.style;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ContinuationItem.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Button from './Button.js';
import NavigationEndpoint from './NavigationEndpoint.js';

export default class ContinuationItem extends YTNode {
  static type = 'ContinuationItem';

  trigger: string;
  button?: Button | null;
  endpoint: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.trigger = data.trigger;

    if (Reflect.has(data, 'button')) {
      this.button = Parser.parseItem(data.button, Button);
    }

    this.endpoint = new NavigationEndpoint(data.continuationEndpoint);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ConversationBar.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Message from './Message.js';

export default class ConversationBar extends YTNode {
  static type = 'ConversationBar';

  availability_message: Message | null;

  constructor(data: RawNode) {
    super();
    this.availability_message = Parser.parseItem(data.availabilityMessage, Message);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/CopyLink.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Button from './Button.js';

export default class CopyLink extends YTNode {
  static type = 'CopyLink';

  copy_button: Button | null;
  short_url: string;
  style: string;

  constructor(data: RawNode) {
    super();
    this.copy_button = Parser.parseItem(data.copyButton, Button);
    this.short_url = data.shortUrl;
    this.style = data.style;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/CreatePlaylistDialog.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Button from './Button.js';
import Dropdown from './Dropdown.js';
import Text from './misc/Text.js';

export default class CreatePlaylistDialog extends YTNode {
  static type = 'CreatePlaylistDialog';

  title: string;
  title_placeholder: string;
  privacy_option: Dropdown | null;
  cancel_button: Button | null;
  create_button: Button | null;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.dialogTitle).toString();
    this.title_placeholder = data.titlePlaceholder || '';
    this.privacy_option = Parser.parseItem(data.privacyOption, Dropdown);
    this.create_button = Parser.parseItem(data.cancelButton, Button);
    this.cancel_button = Parser.parseItem(data.cancelButton, Button);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/DecoratedAvatarView.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import AvatarView from './AvatarView.js';

export default class DecoratedAvatarView extends YTNode {
  static type = 'DecoratedAvatarView';

  avatar: AvatarView | null;
  a11y_label: string;
  on_tap_endpoint?: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.avatar = Parser.parseItem(data.avatar, AvatarView);
    this.a11y_label = data.a11yLabel;
    if (data.rendererContext?.commandContext?.onTap) {
      this.on_tap_endpoint = new NavigationEndpoint(data.rendererContext.commandContext.onTap);
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/DecoratedPlayerBar.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import { Parser } from '../index.js';
import Button from './Button.js';
import MultiMarkersPlayerBar from './MultiMarkersPlayerBar.js';

export default class DecoratedPlayerBar extends YTNode {
  static type = 'DecoratedPlayerBar';

  player_bar: MultiMarkersPlayerBar | null;
  player_bar_action_button: Button | null;

  constructor(data: RawNode) {
    super();
    this.player_bar = Parser.parseItem(data.playerBar, MultiMarkersPlayerBar);
    this.player_bar_action_button = Parser.parseItem(data.playerBarActionButton, Button);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/DefaultPromoPanel.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';

export default class DefaultPromoPanel extends YTNode {
  static type = 'DefaultPromoPanel';

  title: Text;
  description: Text;
  endpoint: NavigationEndpoint;
  large_form_factor_background_thumbnail: YTNode;
  small_form_factor_background_thumbnail: YTNode;
  scrim_color_values: number[];
  min_panel_display_duration_ms: number;
  min_video_play_duration_ms: number;
  scrim_duration: number;
  metadata_order: string;
  panel_layout: string;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.description = new Text(data.description);
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    this.large_form_factor_background_thumbnail = Parser.parseItem(data.largeFormFactorBackgroundThumbnail);
    this.small_form_factor_background_thumbnail = Parser.parseItem(data.smallFormFactorBackgroundThumbnail);
    this.scrim_color_values = data.scrimColorValues;
    this.min_panel_display_duration_ms = data.minPanelDisplayDurationMs;
    this.min_video_play_duration_ms = data.minVideoPlayDurationMs;
    this.scrim_duration = data.scrimDuration;
    this.metadata_order = data.metadataOrder;
    this.panel_layout = data.panelLayout;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/DescriptionPreviewView.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import EngagementPanelSectionList from './EngagementPanelSectionList.js';
import Text from './misc/Text.js';

export default class DescriptionPreviewView extends YTNode {
  static type = 'DescriptionPreviewView';

  description: Text;
  max_lines: number;
  truncation_text: Text;
  always_show_truncation_text: boolean;
  more_endpoint?: {
    show_engagement_panel_endpoint: {
      engagement_panel: EngagementPanelSectionList | null,
      engagement_panel_popup_type: string;
      identifier: {
        surface: string,
        tag: string
      }
    }
  };

  constructor(data: RawNode) {
    super();

    this.description = Text.fromAttributed(data.description);
    this.max_lines = parseInt(data.maxLines);
    this.truncation_text = Text.fromAttributed(data.truncationText);
    this.always_show_truncation_text = !!data.alwaysShowTruncationText;

    if (data.rendererContext.commandContext?.onTap?.innertubeCommand?.showEngagementPanelEndpoint) {
      const endpoint = data.rendererContext.commandContext?.onTap?.innertubeCommand?.showEngagementPanelEndpoint;

      this.more_endpoint = {
        show_engagement_panel_endpoint: {
          engagement_panel: Parser.parseItem(endpoint.engagementPanel, EngagementPanelSectionList),
          engagement_panel_popup_type: endpoint.engagementPanelPresentationConfigs.engagementPanelPopupPresentationConfig.popupType,
          identifier: {
            surface: endpoint.identifier.surface,
            tag: endpoint.identifier.tag
          }
        }
      };
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/DidYouMean.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';

export default class DidYouMean extends YTNode {
  static type = 'DidYouMean';

  text: string;
  corrected_query: Text;
  endpoint: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.text = new Text(data.didYouMean).toString();
    this.corrected_query = new Text(data.correctedQuery);
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint || data.correctedQueryEndpoint);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/DislikeButtonView.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import ToggleButtonView from './ToggleButtonView.js';

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;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/DownloadButton.ts:

import NavigationEndpoint from './NavigationEndpoint.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class DownloadButton extends YTNode {
  static type = 'DownloadButton';

  style: string;
  size: string; // TODO: check this
  endpoint: NavigationEndpoint;
  target_id: string;

  constructor(data: RawNode) {
    super();
    this.style = data.style;
    this.size = data.size;
    this.endpoint = new NavigationEndpoint(data.command);
    this.target_id = data.targetId;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/Dropdown.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import DropdownItem from './DropdownItem.js';

export default class Dropdown extends YTNode {
  static type = 'Dropdown';

  label: string;
  entries: ObservedArray<DropdownItem>;

  constructor(data: RawNode) {
    super();
    this.label = data.label || '';
    this.entries = Parser.parseArray(data.entries, DropdownItem);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/DropdownItem.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Text from './misc/Text.js';
import NavigationEndpoint from './NavigationEndpoint.js';

export default class DropdownItem extends YTNode {
  static type = 'DropdownItem';

  label: string;
  selected: boolean;
  value?: number | string;
  icon_type?: string;
  description?: Text;
  endpoint?: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.label = new Text(data.label).toString();
    this.selected = !!data.isSelected;

    if (Reflect.has(data, 'int32Value')) {
      this.value = data.int32Value;
    } else if (data.stringValue) {
      this.value = data.stringValue;
    }

    if (Reflect.has(data, 'onSelectCommand')) {
      this.endpoint = new NavigationEndpoint(data.onSelectCommand);
    }

    if (Reflect.has(data, 'icon')) {
      this.icon_type = data.icon?.iconType;
    }

    if (Reflect.has(data, 'descriptionText')) {
      this.description = new Text(data.descriptionText);
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/DynamicTextView.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Text from './misc/Text.js';

export default class DynamicTextView extends YTNode {
  static type = 'DynamicTextView';

  text: Text;
  max_lines: number;

  constructor(data: RawNode) {
    super();
    this.text = Text.fromAttributed(data.text);
    this.max_lines = parseInt(data.maxLines);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/Element.ts:

import { Parser, type RawNode } from '../index.js';
import ChildElement from './misc/ChildElement.js';
import { type ObservedArray, YTNode, observe } from '../helpers.js';

export default class Element extends YTNode {
  static type = 'Element';

  model?: YTNode;
  child_elements?: ObservedArray<ChildElement>;

  constructor(data: RawNode) {
    super();

    if (Reflect.has(data, 'elementRenderer')) {
      return Parser.parseItem(data, Element) as Element;
    }

    const type = data.newElement.type.componentType;

    this.model = Parser.parseItem(type?.model);

    if (Reflect.has(data, 'newElement') && Reflect.has(data.newElement, 'childElements')) {
      this.child_elements = observe(data.newElement.childElements?.map((el: RawNode) => new ChildElement(el)) || []);
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/EmergencyOnebox.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Menu from './menus/Menu.js';
import Text from './misc/Text.js';

export default class EmergencyOnebox extends YTNode {
  static type = 'EmergencyOnebox';

  title: Text;
  first_option: YTNode;
  menu: Menu | null;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.first_option = Parser.parseItem(data.firstOption);
    this.menu = Parser.parseItem(data.menu, Menu);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/EmojiPickerCategory.ts:

import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import { type RawNode } from '../index.js';

export default class EmojiPickerCategory extends YTNode {
  static type = 'EmojiPickerCategory';

  category_id: string;
  title: Text;
  emoji_ids: string[];
  image_loading_lazy: boolean;
  category_type: string;

  constructor(data: RawNode) {
    super();
    this.category_id = data.categoryId;
    this.title = new Text(data.title);
    this.emoji_ids = data.emojiIds;
    this.image_loading_lazy = !!data.imageLoadingLazy;
    this.category_type = data.categoryType;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/EmojiPickerCategoryButton.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class EmojiPickerCategoryButton extends YTNode {
  static type = 'EmojiPickerCategoryButton';

  category_id: string;
  icon_type?: string;
  tooltip: string;

  constructor(data: RawNode) {
    super();
    this.category_id = data.categoryId;

    if (Reflect.has(data, 'icon')) {
      this.icon_type = data.icon?.iconType;
    }

    this.tooltip = data.tooltip;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/EmojiPickerUpsellCategory.ts:

import Text from './misc/Text.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class EmojiPickerUpsellCategory extends YTNode {
  static type = 'EmojiPickerUpsellCategory';

  category_id: string;
  title: Text;
  upsell: Text;
  emoji_tooltip: string;
  endpoint: NavigationEndpoint;
  emoji_ids: string[];

  constructor(data: RawNode) {
    super();
    this.category_id = data.categoryId;
    this.title = new Text(data.title);
    this.upsell = new Text(data.upsell);
    this.emoji_tooltip = data.emojiTooltip;
    this.endpoint = new NavigationEndpoint(data.command);
    this.emoji_ids = data.emojiIds;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/EndScreenPlaylist.ts:

import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import { YTNode } from '../helpers.js';
import { type RawNode } from '../index.js';

export default class EndScreenPlaylist extends YTNode {
  static type = 'EndScreenPlaylist';

  id: string;
  title: Text;
  author: Text;
  endpoint: NavigationEndpoint;
  thumbnails: Thumbnail[];
  video_count: Text;

  constructor(data: RawNode) {
    super();
    this.id = data.playlistId;
    this.title = new Text(data.title);
    this.author = new Text(data.longBylineText);
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
    this.video_count = new Text(data.videoCountText);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/EndScreenVideo.ts:

import { type ObservedArray, YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Author from './misc/Author.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import NavigationEndpoint from './NavigationEndpoint.js';

export default class EndScreenVideo extends YTNode {
  static type = 'EndScreenVideo';

  id: string;
  title: Text;
  thumbnails: Thumbnail[];
  thumbnail_overlays: ObservedArray<YTNode>;
  author: Author;
  endpoint: NavigationEndpoint;
  short_view_count: Text;
  badges: ObservedArray<YTNode>;
  duration: {
    text: string;
    seconds: number;
  };

  constructor(data: RawNode) {
    super();
    this.id = data.videoId;
    this.title = new Text(data.title);
    this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
    this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);
    this.author = new Author(data.shortBylineText, data.ownerBadges);
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    this.short_view_count = new Text(data.shortViewCountText);
    this.badges = Parser.parseArray(data.badges);
    this.duration = {
      text: new Text(data.lengthText).toString(),
      seconds: data.lengthInSeconds
    };
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/Endscreen.ts:

import { Parser } from '../index.js';
import { type ObservedArray, YTNode } from '../helpers.js';

export default class Endscreen extends YTNode {
  static type = 'Endscreen';

  elements: ObservedArray<YTNode>;
  start_ms: string;

  constructor(data: any) {
    super();
    this.elements = Parser.parseArray(data.elements);
    this.start_ms = data.startMs;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/EndscreenElement.ts:

import { Parser, type RawNode } from '../index.js';
import Thumbnail from './misc/Thumbnail.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';
import { type ObservedArray, YTNode } from '../helpers.js';

export default class EndscreenElement extends YTNode {
  static type = 'EndscreenElement';

  style: string;
  title: Text;
  endpoint: NavigationEndpoint;
  image?: Thumbnail[];
  icon?: Thumbnail[];
  metadata?: Text;
  call_to_action?: Text;
  hovercard_button?: YTNode;
  is_subscribe?: boolean;
  playlist_length?: Text;
  thumbnail_overlays?: ObservedArray<YTNode>;
  left: number;
  top: number;
  width: number;
  aspect_ratio: number;
  start_ms: number;
  end_ms: number;
  id: string;

  constructor(data: RawNode) {
    super();
    this.style = data.style;
    this.title = new Text(data.title);
    this.endpoint = new NavigationEndpoint(data.endpoint);

    if (Reflect.has(data, 'image')) {
      this.image = Thumbnail.fromResponse(data.image);
    }

    if (Reflect.has(data, 'icon')) {
      this.icon = Thumbnail.fromResponse(data.icon);
    }

    if (Reflect.has(data, 'metadata')) {
      this.metadata = new Text(data.metadata);
    }

    if (Reflect.has(data, 'callToAction')) {
      this.call_to_action = new Text(data.callToAction);
    }

    if (Reflect.has(data, 'hovercardButton')) {
      this.hovercard_button = Parser.parseItem(data.hovercardButton);
    }

    if (Reflect.has(data, 'isSubscribe')) {
      this.is_subscribe = !!data.isSubscribe;
    }

    if (Reflect.has(data, 'playlistLength')) {
      this.playlist_length = new Text(data.playlistLength);
    }

    if (Reflect.has(data, 'thumbnailOverlays')) {
      this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);
    }

    this.left = parseFloat(data.left);
    this.width = parseFloat(data.width);
    this.top = parseFloat(data.top);
    this.aspect_ratio = parseFloat(data.aspectRatio);
    this.start_ms = parseFloat(data.startMs);
    this.end_ms = parseFloat(data.endMs);
    this.id = data.id;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/EngagementPanelSectionList.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import ClipSection from './ClipSection.js';
import ContinuationItem from './ContinuationItem.js';
import EngagementPanelTitleHeader from './EngagementPanelTitleHeader.js';
import MacroMarkersList from './MacroMarkersList.js';
import ProductList from './ProductList.js';
import SectionList from './SectionList.js';
import StructuredDescriptionContent from './StructuredDescriptionContent.js';
import VideoAttributeView from './VideoAttributeView.js';

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

  header: EngagementPanelTitleHeader | null;
  content: VideoAttributeView | SectionList | ContinuationItem | ClipSection | StructuredDescriptionContent | MacroMarkersList | ProductList | null;
  target_id?: string;
  panel_identifier?: string;
  identifier?: {
    surface: string,
    tag: string
  };
  visibility?: string;

  constructor(data: RawNode) {
    super();
    this.header = Parser.parseItem(data.header, EngagementPanelTitleHeader);
    this.content = Parser.parseItem(data.content, [ VideoAttributeView, SectionList, ContinuationItem, ClipSection, StructuredDescriptionContent, MacroMarkersList, ProductList ]);
    this.panel_identifier = data.panelIdentifier;
    this.identifier = data.identifier ? {
      surface: data.identifier.surface,
      tag: data.identifier.tag
    } : undefined;
    this.target_id = data.targetId;
    this.visibility = data.visibility;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/EngagementPanelTitleHeader.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Text from './misc/Text.js';
import Button from './Button.js';

export default class EngagementPanelTitleHeader extends YTNode {
  static type = 'EngagementPanelTitleHeader';

  title: Text;
  visibility_button: Button | null;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.visibility_button = Parser.parseItem(data.visibilityButton, Button);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/EomSettingsDisclaimer.ts:

import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import { type RawNode } from '../index.js';

export default class EomSettingsDisclaimer extends YTNode {
  static type = 'EomSettingsDisclaimer';

  disclaimer: Text;
  info_icon: {
    icon_type: string
  };
  usage_scenario: string;

  constructor(data: RawNode) {
    super();
    this.disclaimer = new Text(data.disclaimer);
    this.info_icon = {
      icon_type: data.infoIcon.iconType
    };
    this.usage_scenario = data.usageScenario;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ExpandableMetadata.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Button from './Button.js';
import HorizontalCardList from './HorizontalCardList.js';
import HorizontalList from './HorizontalList.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';

export default class ExpandableMetadata extends YTNode {
  static type = 'ExpandableMetadata';

  header?: {
    collapsed_title: Text;
    collapsed_thumbnail: Thumbnail[];
    collapsed_label: Text;
    expanded_title: Text;
  };

  expanded_content: HorizontalCardList | HorizontalList | null;
  expand_button: Button | null;
  collapse_button: Button | null;

  constructor(data: RawNode) {
    super();

    if (Reflect.has(data, 'header')) {
      this.header = {
        collapsed_title: new Text(data.header.collapsedTitle),
        collapsed_thumbnail: Thumbnail.fromResponse(data.header.collapsedThumbnail),
        collapsed_label: new Text(data.header.collapsedLabel),
        expanded_title: new Text(data.header.expandedTitle)
      };
    }

    this.expanded_content = Parser.parseItem(data.expandedContent, [ HorizontalCardList, HorizontalList ]);
    this.expand_button = Parser.parseItem(data.expandButton, Button);
    this.collapse_button = Parser.parseItem(data.collapseButton, Button);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ExpandableTab.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';

export default class ExpandableTab extends YTNode {
  static type = 'ExpandableTab';

  title: string;
  endpoint: NavigationEndpoint;
  selected: boolean;
  content: YTNode;

  constructor(data: RawNode) {
    super();
    this.title = data.title;
    this.endpoint = new NavigationEndpoint(data.endpoint);
    this.selected = data.selected;
    this.content = Parser.parseItem(data.content);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ExpandableVideoDescriptionBody.ts:

import { YTNode } from '../helpers.js';
import { Text } from '../misc.js';

import type { RawNode } from '../index.js';

export default class ExpandableVideoDescriptionBody extends YTNode {
  static type = 'ExpandableVideoDescriptionBody';

  show_more_text: Text;
  show_less_text: Text;
  attributed_description_body_text?: string;

  constructor(data: RawNode) {
    super();
    this.show_more_text = new Text(data.showMoreText);
    this.show_less_text = new Text(data.showLessText);

    if (Reflect.has(data, 'attributedDescriptionBodyText')) {
      this.attributed_description_body_text = data.attributedDescriptionBodyText?.content;
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ExpandedShelfContents.ts:

import { Parser, type RawNode } from '../index.js';
import { type ObservedArray, YTNode } from '../helpers.js';

export default class ExpandedShelfContents extends YTNode {
  static type = 'ExpandedShelfContents';

  items: ObservedArray<YTNode>;

  constructor(data: RawNode) {
    super();
    this.items = Parser.parseArray(data.items);
  }

  // XXX: Alias for consistency.
  get contents() {
    return this.items;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/Factoid.ts:

import { YTNode } from '../helpers.js';
import { type RawNode } from '../index.js';
import { Text } from '../misc.js';

export default class Factoid extends YTNode {
  static type = 'Factoid';

  label: Text;
  value: Text;
  accessibility_text: String;

  constructor(data: RawNode) {
    super();
    this.label = new Text(data.label);
    this.value = new Text(data.value);
    this.accessibility_text = data.accessibilityText;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/FancyDismissibleDialog.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import { Text } from '../misc.js';

export default class FancyDismissibleDialog extends YTNode {
  static type = 'FancyDismissibleDialog';

  dialog_message: Text;
  confirm_label: Text;

  constructor(data: RawNode) {
    super();
    this.dialog_message = new Text(data.dialogMessage);
    this.confirm_label = new Text(data.confirmLabel);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/FeedFilterChipBar.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import ChipCloudChip from './ChipCloudChip.js';

export default class FeedFilterChipBar extends YTNode {
  static type = 'FeedFilterChipBar';

  contents: ObservedArray<ChipCloudChip>;

  constructor(data: RawNode) {
    super();
    this.contents = Parser.parseArray(data.contents, ChipCloudChip);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/FeedNudge.ts:

import { YTNode } from '../helpers.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';

import type { RawNode } from '../types/index.js';

export default class FeedNudge extends YTNode {
  static type = 'FeedNudge';

  title: Text;
  subtitle: Text;
  endpoint: NavigationEndpoint;
  apply_modernized_style: boolean;
  trim_style: string;
  background_style: string;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.subtitle = new Text(data.subtitle);
    this.endpoint = new NavigationEndpoint(data.impressionEndpoint);
    this.apply_modernized_style = data.applyModernizedStyle;
    this.trim_style = data.trimStyle;
    this.background_style = data.backgroundStyle;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/FeedTabbedHeader.ts:

import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class FeedTabbedHeader extends YTNode {
  static type = 'FeedTabbedHeader';

  title: Text;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/FlexibleActionsView.ts:

import { type ObservedArray, YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import ButtonView from './ButtonView.js';
import ToggleButtonView from './ToggleButtonView.js';

export type ActionRow = {
  actions: ObservedArray<ButtonView | ToggleButtonView>;
};

export default class FlexibleActionsView extends YTNode {
  static type = 'FlexibleActionsView';

  actions_rows: ActionRow[];
  style: string;

  constructor(data: RawNode) {
    super();
    this.actions_rows = data.actionsRows.map((row: RawNode) => ({
      actions: Parser.parseArray(row.actions, [ ButtonView, ToggleButtonView ])
    }));
    this.style = data.style;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/GameCard.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';

export default class GameCard extends YTNode {
  static type = 'GameCard';

  game: YTNode;

  constructor(data: RawNode) {
    super();
    this.game = Parser.parseItem(data.game);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/GameDetails.ts:

import { YTNode } from '../helpers.js';
import { type RawNode } from '../index.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import NavigationEndpoint from './NavigationEndpoint.js';

export default class GameDetails extends YTNode {
  static type = 'GameDetails';

  title: Text;
  box_art: Thumbnail[];
  box_art_overlay_text: Text;
  endpoint: NavigationEndpoint;
  is_official_box_art: boolean;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.box_art = Thumbnail.fromResponse(data.boxArt);
    this.box_art_overlay_text = new Text(data.boxArtOverlayText);
    this.endpoint = new NavigationEndpoint(data.endpoint);
    this.is_official_box_art = !!data.isOfficialBoxArt;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/Grid.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';

export default class Grid extends YTNode {
  static type = 'Grid';

  items: ObservedArray<YTNode>;
  is_collapsible?: boolean;
  visible_row_count?: string;
  target_id?: string;
  continuation: string | null;
  header?: YTNode;

  constructor(data: RawNode) {
    super();

    this.items = Parser.parseArray(data.items);

    if (Reflect.has(data, 'header')) {
      this.header = Parser.parseItem(data.header);
    }

    if (Reflect.has(data, 'isCollapsible')) {
      this.is_collapsible = data.isCollapsible;
    }

    if (Reflect.has(data, 'visibleRowCount')) {
      this.visible_row_count = data.visibleRowCount;
    }

    if (Reflect.has(data, 'targetId')) {
      this.target_id = data.targetId;
    }

    this.continuation = data.continuations?.[0]?.nextContinuationData?.continuation || null;
  }

  // XXX: Alias for consistency.
  get contents() {
    return this.items;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/GridChannel.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Author from './misc/Author.js';
import Text from './misc/Text.js';
import NavigationEndpoint from './NavigationEndpoint.js';

export default class GridChannel extends YTNode {
  static type = 'GridChannel';

  id: string;
  author: Author;
  subscribers: Text;
  video_count: Text;
  endpoint: NavigationEndpoint;
  subscribe_button: YTNode;

  constructor(data: RawNode) {
    super();
    this.id = data.channelId;

    this.author = new Author({
      ...data.title,
      navigationEndpoint: data.navigationEndpoint
    }, data.ownerBadges, data.thumbnail);

    this.subscribers = new Text(data.subscriberCountText);
    this.video_count = new Text(data.videoCountText);
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    this.subscribe_button = Parser.parseItem(data.subscribeButton);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/GridHeader.ts:

import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class GridHeader extends YTNode {
  static type = 'GridHeader';

  title: Text;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/GridMix.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';

export default class GridMix extends YTNode {
  static type = 'GridMix';

  id: string;
  title: Text;
  author: Text | null;
  thumbnails: Thumbnail[];
  video_count: Text;
  video_count_short: Text;
  endpoint: NavigationEndpoint;
  secondary_endpoint: NavigationEndpoint;
  thumbnail_overlays: ObservedArray<YTNode>;

  constructor(data: RawNode) {
    super();
    this.id = data.playlistId;
    this.title = new Text(data.title);

    this.author = data.shortBylineText?.simpleText ?
      new Text(data.shortBylineText) : data.longBylineText?.simpleText ?
        new Text(data.longBylineText) : null;

    this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
    this.video_count = new Text(data.videoCountText);
    this.video_count_short = new Text(data.videoCountShortText);
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    this.secondary_endpoint = new NavigationEndpoint(data.secondaryNavigationEndpoint);
    this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/GridMovie.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import MetadataBadge from './MetadataBadge.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';

export default class GridMovie extends YTNode {
  static type = 'GridMovie';

  id: string;
  title: Text;
  thumbnails: Thumbnail[];
  duration: Text | null;
  endpoint: NavigationEndpoint;
  badges: ObservedArray<MetadataBadge>;
  metadata: Text;
  thumbnail_overlays: ObservedArray<YTNode>;

  constructor(data: RawNode) {
    super();
    const length_alt = data.thumbnailOverlays.find((overlay: RawNode) => overlay.hasOwnProperty('thumbnailOverlayTimeStatusRenderer'))?.thumbnailOverlayTimeStatusRenderer;
    this.id = data.videoId;
    this.title = new Text(data.title);
    this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
    this.duration = data.lengthText ? new Text(data.lengthText) : length_alt?.text ? new Text(length_alt.text) : null;
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    this.badges = Parser.parseArray(data.badges, MetadataBadge);
    this.metadata = new Text(data.metadata);
    this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/GridPlaylist.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Author from './misc/Author.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import NavigationEndpoint from './NavigationEndpoint.js';

export default class GridPlaylist extends YTNode {
  static type = 'GridPlaylist';

  id: string;
  title: Text;
  author?: Author;
  badges: ObservedArray<YTNode>;
  endpoint: NavigationEndpoint;
  view_playlist: Text;
  thumbnails: Thumbnail[];
  thumbnail_renderer;
  sidebar_thumbnails: Thumbnail[] | null;
  video_count: Text;
  video_count_short: Text;

  constructor(data: RawNode) {
    super();
    this.id = data.playlistId;
    this.title = new Text(data.title);

    if (Reflect.has(data, 'shortBylineText')) {
      this.author = new Author(data.shortBylineText, data.ownerBadges);
    }

    this.badges = Parser.parseArray(data.ownerBadges);
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    this.view_playlist = new Text(data.viewPlaylistText);
    this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
    this.thumbnail_renderer = Parser.parseItem(data.thumbnailRenderer);
    this.sidebar_thumbnails = [].concat(...data.sidebarThumbnails?.map((thumbnail: any) => Thumbnail.fromResponse(thumbnail)) || []) || null;
    this.video_count = new Text(data.thumbnailText);
    this.video_count_short = new Text(data.videoCountShortText);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/GridShow.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import type { RawNode } from '../index.js';
import * as Parser from '../parser.js';
import Author from './misc/Author.js';
import Text from './misc/Text.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import ShowCustomThumbnail from './ShowCustomThumbnail.js';
import ThumbnailOverlayBottomPanel from './ThumbnailOverlayBottomPanel.js';

export default class GridShow extends YTNode {
  static type = 'GridShow';

  title: Text;
  thumbnail_renderer: ShowCustomThumbnail | null;
  endpoint: NavigationEndpoint;
  long_byline_text: Text;
  thumbnail_overlays: ObservedArray<ThumbnailOverlayBottomPanel> | null;
  author: Author;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.thumbnail_renderer = Parser.parseItem(data.thumbnailRenderer, ShowCustomThumbnail);
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    this.long_byline_text = new Text(data.longBylineText);
    this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays, ThumbnailOverlayBottomPanel);
    this.author = new Author(data.shortBylineText, undefined);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/GridVideo.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Menu from './menus/Menu.js';
import Author from './misc/Author.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';

export default class GridVideo extends YTNode {
  static type = 'GridVideo';

  id: string;
  title: Text;
  thumbnails: Thumbnail[];
  thumbnail_overlays: ObservedArray<YTNode>;
  rich_thumbnail: YTNode;
  published: Text;
  duration: Text | null;
  author: Author;
  views: Text;
  short_view_count: Text;
  endpoint: NavigationEndpoint;
  menu: Menu | null;
  buttons?: ObservedArray<YTNode>;
  upcoming?: Date;
  upcoming_text?: Text;
  is_reminder_set?: boolean;

  constructor(data: RawNode) {
    super();
    const length_alt = data.thumbnailOverlays.find((overlay: RawNode) => overlay.hasOwnProperty('thumbnailOverlayTimeStatusRenderer'))?.thumbnailOverlayTimeStatusRenderer;

    this.id = data.videoId;
    this.title = new Text(data.title);
    this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
    this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);
    this.rich_thumbnail = Parser.parseItem(data.richThumbnail);
    this.published = new Text(data.publishedTimeText);
    this.duration = data.lengthText ? new Text(data.lengthText) : length_alt?.text ? new Text(length_alt.text) : null;
    this.author = data.shortBylineText && new Author(data.shortBylineText, data.ownerBadges);
    this.views = new Text(data.viewCountText);
    this.short_view_count = new Text(data.shortViewCountText);
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    this.menu = Parser.parseItem(data.menu, Menu);

    if (Reflect.has(data, 'buttons')) {
      this.buttons = Parser.parseArray(data.buttons);
    }

    if (Reflect.has(data, 'upcomingEventData')) {
      this.upcoming = new Date(Number(`${data.upcomingEventData.startTime}000`));
      this.upcoming_text = new Text(data.upcomingEventData.upcomingEventText);
      this.is_reminder_set = !!data.upcomingEventData?.isReminderSet;
    }
  }

  get is_upcoming(): boolean {
    return Boolean(this.upcoming && this.upcoming > new Date());
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/GuideCollapsibleEntry.ts:

import * as Parser from '../parser.js';
import GuideEntry from './GuideEntry.js';
import type { RawNode } from '../index.js';
import { type ObservedArray, YTNode } from '../helpers.js';

export default class GuideCollapsibleEntry extends YTNode {
  static type = 'GuideCollapsibleEntry';

  expander_item: GuideEntry | null;
  collapser_item: GuideEntry | null;
  expandable_items: ObservedArray<YTNode>;

  constructor(data: RawNode) {
    super();
    this.expander_item = Parser.parseItem(data.expanderItem, GuideEntry);
    this.collapser_item = Parser.parseItem(data.collapserItem, GuideEntry);
    this.expandable_items = Parser.parseArray(data.expandableItems);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/GuideCollapsibleSectionEntry.ts:

import * as Parser from '../parser.js';
import type { RawNode } from '../index.js';
import { type ObservedArray, YTNode } from '../helpers.js';

export default class GuideCollapsibleSectionEntry extends YTNode {
  static type = 'GuideCollapsibleSectionEntry';

  header_entry: YTNode;
  expander_icon: string;
  collapser_icon: string;
  section_items: ObservedArray<YTNode>;

  constructor(data: RawNode) {
    super();
    this.header_entry = Parser.parseItem(data.headerEntry);
    this.expander_icon = data.expanderIcon.iconType;
    this.collapser_icon = data.collapserIcon.iconType;
    this.section_items = Parser.parseArray(data.sectionItems);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/GuideDownloadsEntry.ts:

import GuideEntry from './GuideEntry.js';
import type { RawNode } from '../index.js';

export default class GuideDownloadsEntry extends GuideEntry {
  static type = 'GuideDownloadsEntry';

  always_show: boolean;

  constructor(data: RawNode) {
    super(data.entryRenderer.guideEntryRenderer);
    this.always_show = !!data.alwaysShow;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/GuideEntry.ts:

import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class GuideEntry extends YTNode {
  static type = 'GuideEntry';

  title: Text;
  endpoint: NavigationEndpoint;
  icon_type?: string;
  thumbnails?: Thumbnail[];
  badges?: any;
  is_primary: boolean;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.formattedTitle);
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint || data.serviceEndpoint);

    if (Reflect.has(data, 'icon') && Reflect.has(data.icon, 'iconType')) {
      this.icon_type = data.icon.iconType;
    }

    if (Reflect.has(data, 'thumbnail')) {
      this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
    }

    // (LuanRT) XXX: Check this property's data and parse it.
    if (Reflect.has(data, 'badges')) {
      this.badges = data.badges;
    }

    this.is_primary = !!data.isPrimary;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/GuideSection.ts:

import Text from './misc/Text.js';
import * as Parser from '../parser.js';
import { type ObservedArray, YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class GuideSection extends YTNode {
  static type = 'GuideSection';

  title?: Text;
  items: ObservedArray<YTNode>;

  constructor(data: RawNode) {
    super();
    if (Reflect.has(data, 'formattedTitle')) {
      this.title = new Text(data.formattedTitle);
    }

    this.items = Parser.parseArray(data.items);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/GuideSubscriptionsSection.ts:

import GuideSection from './GuideSection.js';

export default class GuideSubscriptionsSection extends GuideSection {
  static type = 'GuideSubscriptionsSection';
}

LuanRT/YouTube.js/blob/main/src/parser/classes/HashtagHeader.ts:

import { YTNode } from '../helpers.js';
import Text from './misc/Text.js';
import type { RawNode } from '../index.js';

export default class HashtagHeader extends YTNode {
  static type = 'HashtagHeader';

  hashtag: Text;
  hashtag_info: Text;

  constructor(data: RawNode) {
    super();
    this.hashtag = new Text(data.hashtag);
    this.hashtag_info = new Text(data.hashtagInfoText);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/HashtagTile.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import { Thumbnail } from '../misc.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';

export default class HashtagTile extends YTNode {
  static type = 'HashtagTile';

  hashtag: Text;
  hashtag_info_text: Text;
  hashtag_thumbnail: Thumbnail[];
  endpoint: NavigationEndpoint;
  hashtag_background_color: number;
  hashtag_video_count: Text;
  hashtag_channel_count: Text;

  constructor(data: RawNode) {
    super();
    this.hashtag = new Text(data.hashtag);
    this.hashtag_info_text = new Text(data.hashtagInfoText);
    this.hashtag_thumbnail = Thumbnail.fromResponse(data.hashtagThumbnail);
    this.endpoint = new NavigationEndpoint(data.onTapCommand);
    this.hashtag_background_color = data.hashtagBackgroundColor;
    this.hashtag_video_count = new Text(data.hashtagVideoCount);
    this.hashtag_channel_count = new Text(data.hashtagChannelCount);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/HeatMarker.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class HeatMarker extends YTNode {
  static type = 'HeatMarker';

  time_range_start_millis: number;
  marker_duration_millis: number;
  heat_marker_intensity_score_normalized: number;

  constructor(data: RawNode) {
    super();
    this.time_range_start_millis = data.timeRangeStartMillis;
    this.marker_duration_millis = data.markerDurationMillis;
    this.heat_marker_intensity_score_normalized = data.heatMarkerIntensityScoreNormalized;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/Heatmap.ts:

import { Parser, type RawNode } from '../index.js';
import HeatMarker from './HeatMarker.js';
import { type ObservedArray, YTNode } from '../helpers.js';

export default class Heatmap extends YTNode {
  static type = 'Heatmap';

  max_height_dp: number;
  min_height_dp: number;
  show_hide_animation_duration_millis: number;
  heat_markers: ObservedArray<HeatMarker>;
  heat_markers_decorations: ObservedArray<YTNode>;

  constructor(data: RawNode) {
    super();
    this.max_height_dp = data.maxHeightDp;
    this.min_height_dp = data.minHeightDp;
    this.show_hide_animation_duration_millis = data.showHideAnimationDurationMillis;
    this.heat_markers = Parser.parseArray(data.heatMarkers, HeatMarker);
    this.heat_markers_decorations = Parser.parseArray(data.heatMarkersDecorations);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/HeroPlaylistThumbnail.ts:

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 HeroPlaylistThumbnail extends YTNode {
  static type = 'HeroPlaylistThumbnail';

  thumbnails: Thumbnail[];
  on_tap_endpoint: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
    this.on_tap_endpoint = new NavigationEndpoint(data.onTap);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/HighlightsCarousel.ts:

import NavigationEndpoint from './NavigationEndpoint.js';
import Thumbnail from './misc/Thumbnail.js';
import { YTNode, observe } from '../helpers.js';
import { type RawNode } from '../index.js';

export class Panel extends YTNode {
  static type = 'Panel';

  thumbnail?: {
    image: Thumbnail[];
    endpoint: NavigationEndpoint;
    on_long_press_endpoint: NavigationEndpoint;
    content_mode: string;
    crop_options: string;
  };

  background_image: {
    image: Thumbnail[];
    gradient_image: Thumbnail[];
  };

  strapline: string;
  title: string;
  description: string;
  text_on_tap_endpoint: NavigationEndpoint;

  cta: {
    icon_name: string;
    title: string;
    endpoint: NavigationEndpoint;
    accessibility_text: string;
    state: string;
  };

  constructor(data: RawNode) {
    super();

    if (data.thumbnail) {
      this.thumbnail = {
        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,
        crop_options: data.thumbnail.cropOptions
      };
    }

    this.background_image = {
      image: Thumbnail.fromResponse(data.backgroundImage.image),
      gradient_image: Thumbnail.fromResponse(data.backgroundImage.gradientImage)
    };

    this.strapline = data.strapline;
    this.title = data.title;
    this.description = data.description;

    this.cta = {
      icon_name: data.cta.iconName,
      title: data.cta.title,
      endpoint: new NavigationEndpoint(data.cta.onTap),
      accessibility_text: data.cta.accessibilityText,
      state: data.cta.state
    };

    this.text_on_tap_endpoint = new NavigationEndpoint(data.textOnTap);
  }
}

export default class HighlightsCarousel extends YTNode {
  static type = 'HighlightsCarousel';

  panels: Panel[];

  constructor(data: RawNode) {
    super();
    this.panels = observe(data.highlightsCarousel.panels.map((el: RawNode) => new Panel(el)));
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/HistorySuggestion.ts:

import type { RawNode } from '../index.js';
import SearchSuggestion from './SearchSuggestion.js';

export default class HistorySuggestion extends SearchSuggestion {
  static type = 'HistorySuggestion';

  constructor(data: RawNode) {
    super(data);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/HorizontalCardList.ts:

import { Parser, type RawNode } from '../index.js';
import { type ObservedArray, YTNode } from '../helpers.js';
import SearchRefinementCard from './SearchRefinementCard.js';
import Button from './Button.js';
import MacroMarkersListItem from './MacroMarkersListItem.js';
import GameCard from './GameCard.js';
import VideoCard from './VideoCard.js';
import VideoAttributeView from './VideoAttributeView.js';

export default class HorizontalCardList extends YTNode {
  static type = 'HorizontalCardList';

  cards: ObservedArray<VideoAttributeView | SearchRefinementCard | MacroMarkersListItem | GameCard | VideoCard>;
  header: YTNode;
  previous_button: Button | null;
  next_button: Button | null;

  constructor(data: RawNode) {
    super();
    this.cards = Parser.parseArray(data.cards, [ VideoAttributeView, SearchRefinementCard, MacroMarkersListItem, GameCard, VideoCard ]);
    this.header = Parser.parseItem(data.header);
    this.previous_button = Parser.parseItem(data.previousButton, Button);
    this.next_button = Parser.parseItem(data.nextButton, Button);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/HorizontalList.ts:

import { Parser, type RawNode } from '../index.js';
import { type ObservedArray, YTNode } from '../helpers.js';

export default class HorizontalList extends YTNode {
  static type = 'HorizontalList';

  visible_item_count: string;
  items: ObservedArray<YTNode>;

  constructor(data: RawNode) {
    super();
    this.visible_item_count = data.visibleItemCount;
    this.items = Parser.parseArray(data.items);
  }

  // XXX: Alias for consistency.
  get contents() {
    return this.items;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/HorizontalMovieList.ts:

import { Parser, type RawNode } from '../index.js';
import { type ObservedArray, YTNode } from '../helpers.js';
import Button from './Button.js';

export default class HorizontalMovieList extends YTNode {
  static type = 'HorizontalMovieList';

  items: ObservedArray<YTNode>;
  previous_button: Button | null;
  next_button: Button | null;

  constructor(data: RawNode) {
    super();
    this.items = Parser.parseArray(data.items);
    this.previous_button = Parser.parseItem(data.previousButton, Button);
    this.next_button = Parser.parseItem(data.nextButton, Button);
  }

  // XXX: Alias for consistency.
  get contents() {
    return this.items;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/IconLink.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';

export default class IconLink extends YTNode {
  static type = 'IconLink';

  icon_type: string;
  tooltip?: string;
  endpoint: NavigationEndpoint;

  constructor(data: RawNode) {
    super();

    this.icon_type = data.icon?.iconType;

    if (Reflect.has(data, 'tooltip')) {
      this.tooltip = new Text(data.tooltip).toString();
    }

    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ImageBannerView.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Thumbnail from './misc/Thumbnail.js';

export default class ImageBannerView extends YTNode {
  static type = 'ImageBannerView';

  image: Thumbnail[];
  style: string;

  constructor(data: RawNode) {
    super();
    this.image = Thumbnail.fromResponse(data.image);
    this.style = data.style;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/IncludingResultsFor.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';

export default class IncludingResultsFor extends YTNode {
  static type = 'IncludingResultsFor';

  including_results_for: Text;
  corrected_query: Text;
  corrected_query_endpoint: NavigationEndpoint;
  search_only_for?: Text;
  original_query?: Text;
  original_query_endpoint?: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.including_results_for = new Text(data.includingResultsFor);
    this.corrected_query = new Text(data.correctedQuery);
    this.corrected_query_endpoint = new NavigationEndpoint(data.correctedQueryEndpoint);
    this.search_only_for = Reflect.has(data, 'searchOnlyFor') ? new Text(data.searchOnlyFor) : undefined;
    this.original_query = Reflect.has(data, 'originalQuery') ? new Text(data.originalQuery) : undefined;
    this.original_query_endpoint = Reflect.has(data, 'originalQueryEndpoint') ? new NavigationEndpoint(data.originalQueryEndpoint) : undefined;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/InfoPanelContainer.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import InfoPanelContent from './InfoPanelContent.js';
import Menu from './menus/Menu.js';
import Text from './misc/Text.js';
import NavigationEndpoint from './NavigationEndpoint.js';

export default class InfoPanelContainer extends YTNode {
  static type = 'InfoPanelContainer';

  title: Text;
  menu: Menu | null;
  content: InfoPanelContent | null;
  header_endpoint?: NavigationEndpoint;
  background: string;
  title_style?: string;
  icon_type?: string;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.menu = Parser.parseItem(data.menu, Menu);
    this.content = Parser.parseItem(data.content, InfoPanelContent);

    if (data.headerEndpoint)
      this.header_endpoint = new NavigationEndpoint(data.headerEndpoint);

    this.background = data.background;
    this.title_style = data.titleStyle;

    if (Reflect.has(data, 'icon')) {
      this.icon_type = data.icon?.iconType;
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/InfoPanelContent.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import type { AttributedText } from './misc/Text.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import NavigationEndpoint from './NavigationEndpoint.js';

export default class InfoPanelContent extends YTNode {
  static type = 'InfoPanelContent';

  title: Text;
  source: Text;
  paragraphs?: Text[];
  attributed_paragraphs?: Text[];
  thumbnail: Thumbnail[];
  source_endpoint: NavigationEndpoint;
  truncate_paragraphs: boolean;
  background: string;
  inline_link_icon_type?: string;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.source = new Text(data.source);

    if (Reflect.has(data, 'paragraphs'))
      this.paragraphs = data.paragraphs.map((p: RawNode) => new Text(p));

    if (Reflect.has(data, 'attributedParagraphs'))
      this.attributed_paragraphs = data.attributedParagraphs.map((p: AttributedText) => Text.fromAttributed(p));

    this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
    this.source_endpoint = new NavigationEndpoint(data.sourceEndpoint);
    this.truncate_paragraphs = !!data.truncateParagraphs;
    this.background = data.background;

    if (Reflect.has(data, 'inlineLinkIcon') && Reflect.has(data.inlineLinkIcon, 'iconType')) {
      this.inline_link_icon_type = data.inlineLinkIcon.iconType;
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/InfoRow.ts:

import { YTNode } from '../helpers.js';
import { Text } from '../misc.js';
import type { RawNode } from '../index.js';

export default class InfoRow extends YTNode {
  static type = 'InfoRow';

  title: Text;
  default_metadata?: Text;
  expanded_metadata?: Text;
  info_row_expand_status_key?: String;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);

    if (Reflect.has(data, 'defaultMetadata')) {
      this.default_metadata = new Text(data.defaultMetadata);
    }

    if (Reflect.has(data, 'expandedMetadata')) {
      this.expanded_metadata = new Text(data.expandedMetadata);
    }

    if (Reflect.has(data, 'infoRowExpandStatusKey')) {
      this.info_row_expand_status_key = data.infoRowExpandStatusKey;
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/InteractiveTabbedHeader.ts:

import Button from './Button.js';
import MetadataBadge from './MetadataBadge.js';
import SubscribeButton from './SubscribeButton.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';

export default class InteractiveTabbedHeader extends YTNode {
  static type = 'InteractiveTabbedHeader';

  header_type: string;
  title: Text;
  description: Text;
  metadata: Text;
  badges: MetadataBadge[];
  box_art: Thumbnail[];
  banner: Thumbnail[];
  buttons: ObservedArray<SubscribeButton | Button>;
  auto_generated: Text;

  constructor(data: RawNode) {
    super();
    this.header_type = data.type;
    this.title = new Text(data.title);
    this.description = new Text(data.description);
    this.metadata = new Text(data.metadata);
    this.badges = Parser.parseArray(data.badges, MetadataBadge);
    this.box_art = Thumbnail.fromResponse(data.boxArt);
    this.banner = Thumbnail.fromResponse(data.banner);
    this.buttons = Parser.parseArray(data.buttons, [ SubscribeButton, Button ]);
    this.auto_generated = new Text(data.autoGenerated);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ItemSection.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import ItemSectionHeader from './ItemSectionHeader.js';
import ItemSectionTabbedHeader from './ItemSectionTabbedHeader.js';
import CommentsHeader from './comments/CommentsHeader.js';
import SortFilterHeader from './SortFilterHeader.js';

export default class ItemSection extends YTNode {
  static type = 'ItemSection';

  header: CommentsHeader | ItemSectionHeader | ItemSectionTabbedHeader | SortFilterHeader | null;
  contents: ObservedArray<YTNode>;
  target_id?: string;
  continuation?: string;

  constructor(data: RawNode) {
    super();
    this.header = Parser.parseItem(data.header, [ CommentsHeader, ItemSectionHeader, ItemSectionTabbedHeader, SortFilterHeader ]);
    this.contents = Parser.parseArray(data.contents);

    if (data.targetId || data.sectionIdentifier) {
      this.target_id = data.targetId || data.sectionIdentifier;
    }

    if (data.continuations) {
      this.continuation = data.continuations?.at(0)?.nextContinuationData?.continuation;
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ItemSectionHeader.ts:

import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class ItemSectionHeader extends YTNode {
  static type = 'ItemSectionHeader';

  title: Text;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ItemSectionTab.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';

export default class ItemSectionTab extends YTNode {
  static type = 'Tab';

  title: Text;
  selected: boolean;
  endpoint: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.selected = !!data.selected;
    this.endpoint = new NavigationEndpoint(data.endpoint);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ItemSectionTabbedHeader.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import ItemSectionTab from './ItemSectionTab.js';
import Text from './misc/Text.js';

export default class ItemSectionTabbedHeader extends YTNode {
  static type = 'ItemSectionTabbedHeader';

  title: Text;
  tabs: ObservedArray<ItemSectionTab>;
  end_items?: ObservedArray<YTNode>;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.tabs = Parser.parseArray(data.tabs, ItemSectionTab);
    if (Reflect.has(data, 'endItems')) {
      this.end_items = Parser.parseArray(data.endItems);
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/LikeButton.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';

export default class LikeButton extends YTNode {
  static type = 'LikeButton';

  target: {
    video_id: string;
  };

  like_status: string;
  likes_allowed: string;
  endpoints?: NavigationEndpoint[];

  constructor(data: RawNode) {
    super();

    this.target = {
      video_id: data.target.videoId
    };

    this.like_status = data.likeStatus;
    this.likes_allowed = data.likesAllowed;

    if (Reflect.has(data, 'serviceEndpoints')) {
      this.endpoints = data.serviceEndpoints.map((endpoint: RawNode) => new NavigationEndpoint(endpoint));
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/LikeButtonView.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import ToggleButtonView from './ToggleButtonView.js';

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
    };
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/LiveChat.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Text from './misc/Text.js';

export default class LiveChat extends YTNode {
  static type = 'LiveChat';

  header: YTNode;
  initial_display_state: string;
  continuation: string;

  client_messages: {
    reconnect_message: Text;
    unable_to_reconnect_message: Text;
    fatal_error: Text;
    reconnected_message: Text;
    generic_error: Text;
  };

  is_replay: boolean;

  constructor(data: RawNode) {
    super();
    this.header = Parser.parseItem(data.header);
    this.initial_display_state = data.initialDisplayState;
    this.continuation = data.continuations[0]?.reloadContinuationData?.continuation;

    this.client_messages = {
      reconnect_message: new Text(data.clientMessages.reconnectMessage),
      unable_to_reconnect_message: new Text(data.clientMessages.unableToReconnectMessage),
      fatal_error: new Text(data.clientMessages.fatalError),
      reconnected_message: new Text(data.clientMessages.reconnectedMessage),
      generic_error: new Text(data.clientMessages.genericError)
    };

    this.is_replay = !!data.isReplay;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/LiveChatAuthorBadge.ts:

import type { RawNode } from '../index.js';
import MetadataBadge from './MetadataBadge.js';
import Thumbnail from './misc/Thumbnail.js';

export default class LiveChatAuthorBadge extends MetadataBadge {
  static type = 'LiveChatAuthorBadge';

  custom_thumbnail: Thumbnail[];

  constructor(data: RawNode) {
    super(data);
    this.custom_thumbnail = Thumbnail.fromResponse(data.customThumbnail);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/LiveChatDialog.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Button from './Button.js';
import Text from './misc/Text.js';

export default class LiveChatDialog extends YTNode {
  static type = 'LiveChatDialog';

  confirm_button: Button | null;
  dialog_messages: Text[];

  constructor (data: RawNode) {
    super();
    this.confirm_button = Parser.parseItem(data.confirmButton, Button);
    this.dialog_messages = data.dialogMessages.map((el: RawNode) => new Text(el));
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/LiveChatHeader.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Button from './Button.js';
import SortFilterSubMenu from './SortFilterSubMenu.js';
import Menu from './menus/Menu.js';

export default class LiveChatHeader extends YTNode {
  static type = 'LiveChatHeader';

  overflow_menu: Menu | null;
  collapse_button: Button | null;
  view_selector: SortFilterSubMenu | null;

  constructor(data: RawNode) {
    super();
    this.overflow_menu = Parser.parseItem(data.overflowMenu, Menu);
    this.collapse_button = Parser.parseItem(data.collapseButton, Button);
    this.view_selector = Parser.parseItem(data.viewSelector, SortFilterSubMenu);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/LiveChatItemList.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Button from './Button.js';

export default class LiveChatItemList extends YTNode {
  static type = 'LiveChatItemList';

  max_items_to_display: string;
  more_comments_below_button: Button | null;

  constructor(data: RawNode) {
    super();
    this.max_items_to_display = data.maxItemsToDisplay;
    this.more_comments_below_button = Parser.parseItem(data.moreCommentsBelowButton, Button);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/LiveChatMessageInput.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Button from './Button.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';

export default class LiveChatMessageInput extends YTNode {
  static type = 'LiveChatMessageInput';

  author_name: Text;
  author_photo: Thumbnail[];
  send_button: Button | null;
  target_id: string;

  constructor(data: RawNode) {
    super();
    this.author_name = new Text(data.authorName);
    this.author_photo = Thumbnail.fromResponse(data.authorPhoto);
    this.send_button = Parser.parseItem(data.sendButton, Button);
    this.target_id = data.targetId;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/LiveChatParticipant.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';

export default class LiveChatParticipant extends YTNode {
  static type = 'LiveChatParticipant';

  name: Text;
  photo: Thumbnail[];
  badges: ObservedArray<YTNode>;

  constructor(data: RawNode) {
    super();
    this.name = new Text(data.authorName);
    this.photo = Thumbnail.fromResponse(data.authorPhoto);
    this.badges = Parser.parseArray(data.authorBadges);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/LiveChatParticipantsList.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import LiveChatParticipant from './LiveChatParticipant.js';
import Text from './misc/Text.js';

export default class LiveChatParticipantsList extends YTNode {
  static type = 'LiveChatParticipantsList';

  title: Text;
  participants: ObservedArray<LiveChatParticipant>;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.participants = Parser.parseArray(data.participants, LiveChatParticipant);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/LockupMetadataView.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import ContentMetadataView from './ContentMetadataView.js';
import Text from './misc/Text.js';

export default class LockupMetadataView extends YTNode {
  static type = 'LockupMetadataView';

  title: Text;
  metadata: ContentMetadataView | null;

  constructor(data: RawNode) {
    super();

    this.title = Text.fromAttributed(data.title);
    this.metadata = Parser.parseItem(data.metadata, ContentMetadataView);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/LockupView.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import CollectionThumbnailView from './CollectionThumbnailView.js';
import LockupMetadataView from './LockupMetadataView.js';
import NavigationEndpoint from './NavigationEndpoint.js';

export default class LockupView extends YTNode {
  static type = 'LockupView';

  content_image: CollectionThumbnailView | null;
  metadata: LockupMetadataView | null;
  content_id: string;
  content_type: 'SOURCE' | 'PLAYLIST' | 'ALBUM' | 'PODCAST' | 'SHOPPING_COLLECTION' | 'SHORT' | 'GAME' | 'PRODUCT';
  on_tap_endpoint: NavigationEndpoint;

  constructor(data: RawNode) {
    super();

    this.content_image = Parser.parseItem(data.contentImage, CollectionThumbnailView);
    this.metadata = Parser.parseItem(data.metadata, LockupMetadataView);
    this.content_id = data.contentId;
    this.content_type = data.contentType.replace('LOCKUP_CONTENT_TYPE_', '');
    this.on_tap_endpoint = new NavigationEndpoint(data.rendererContext.commandContext.onTap);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MacroMarkersInfoItem.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Menu from './menus/Menu.js';
import Text from './misc/Text.js';

export default class MacroMarkersInfoItem extends YTNode {
  static type = 'MacroMarkersInfoItem';

  info_text: Text;
  menu: Menu | null;

  constructor(data: RawNode) {
    super();
    this.info_text = new Text(data.infoText);
    this.menu = Parser.parseItem(data.menu, Menu);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MacroMarkersList.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import { Text } from '../misc.js';
import MacroMarkersInfoItem from './MacroMarkersInfoItem.js';
import MacroMarkersListItem from './MacroMarkersListItem.js';

export default class MacroMarkersList extends YTNode {
  static type = 'MacroMarkersList';

  contents: ObservedArray<MacroMarkersInfoItem | MacroMarkersListItem>;
  sync_button_label: Text;

  constructor(data: RawNode) {
    super();
    this.contents = Parser.parseArray(data.contents, [ MacroMarkersInfoItem, MacroMarkersListItem ]);
    this.sync_button_label = new Text(data.syncButtonLabel);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MacroMarkersListItem.ts:

import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import NavigationEndpoint from './NavigationEndpoint.js';

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class MacroMarkersListItem extends YTNode {
  static type = 'MacroMarkersListItem';

  title: Text;
  time_description: Text;
  thumbnail: Thumbnail[];
  on_tap_endpoint: NavigationEndpoint;
  layout: string;
  is_highlighted: boolean;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.time_description = new Text(data.timeDescription);
    this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
    this.on_tap_endpoint = new NavigationEndpoint(data.onTap);
    this.layout = data.layout;
    this.is_highlighted = !!data.isHighlighted;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MerchandiseItem.ts:

import Thumbnail from './misc/Thumbnail.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class MerchandiseItem extends YTNode {
  static type = 'MerchandiseItem';

  title: string;
  description: string;
  thumbnails: Thumbnail[];
  price: string;
  vendor_name: string;
  button_text: string;
  button_accessibility_text: string;
  from_vendor_text: string;
  additional_fees_text: string;
  region_format: string;
  endpoint: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.title = data.title;
    this.description = data.description;
    this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
    this.price = data.price;
    this.vendor_name = data.vendorName;
    this.button_text = data.buttonText;
    this.button_accessibility_text = data.buttonAccessibilityText;
    this.from_vendor_text = data.fromVendorText;
    this.additional_fees_text = data.additionalFeesText;
    this.region_format = data.regionFormat;
    this.endpoint = new NavigationEndpoint(data.buttonCommand);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MerchandiseShelf.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';

export default class MerchandiseShelf extends YTNode {
  static type = 'MerchandiseShelf';

  title: string;
  menu: YTNode;
  items: ObservedArray<YTNode>;

  constructor(data: RawNode) {
    super();
    this.title = data.title;
    this.menu = Parser.parseItem(data.actionButton);
    this.items = Parser.parseArray(data.items);
  }

  // XXX: Alias for consistency.
  get contents() {
    return this.items;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/Message.ts:

import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class Message extends YTNode {
  static type = 'Message';

  text: Text;

  constructor(data: RawNode) {
    super();
    this.text = new Text(data.text);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MetadataBadge.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class MetadataBadge extends YTNode {
  static type = 'MetadataBadge';

  icon_type?: string;
  style?: string;
  label?: string;
  tooltip?: string;

  constructor(data: RawNode) {
    super();
    if (Reflect.has(data, 'icon')) {
      this.icon_type = data.icon.iconType;
    }

    if (Reflect.has(data, 'style')) {
      this.style = data.style;
    }

    if (Reflect.has(data, 'label')) {
      this.label = data.label;
    }

    if (Reflect.has(data, 'tooltip') || Reflect.has(data, 'iconTooltip')) {
      this.tooltip = data.tooltip || data.iconTooltip;
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MetadataRow.ts:

import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class MetadataRow extends YTNode {
  static type = 'MetadataRow';

  title: Text;
  contents: Text[];

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.contents = data.contents.map((content: RawNode) => new Text(content));
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MetadataRowContainer.ts:

import { Parser, type RawNode } from '../index.js';
import { type ObservedArray, YTNode } from '../helpers.js';

export default class MetadataRowContainer extends YTNode {
  static type = 'MetadataRowContainer';

  rows: ObservedArray<YTNode>;
  collapsed_item_count: number;

  constructor(data: RawNode) {
    super();
    this.rows = Parser.parseArray(data.rows);
    this.collapsed_item_count = data.collapsedItemCount;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MetadataRowHeader.ts:

import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class MetadataRowHeader extends YTNode {
  static type = 'MetadataRowHeader';

  content: Text;
  has_divider_line: boolean;

  constructor(data: RawNode) {
    super();
    this.content = new Text(data.content);
    this.has_divider_line = data.hasDividerLine;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MetadataScreen.ts:

import { Parser, type RawNode } from '../index.js';
import { YTNode } from '../helpers.js';

export default class MetadataScreen extends YTNode {
  static type = 'MetadataScreen';

  section_list: YTNode;

  constructor (data: RawNode) {
    super();
    this.section_list = Parser.parseItem(data);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MicroformatData.ts:

import Thumbnail from './misc/Thumbnail.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class MicroformatData extends YTNode {
  static type = 'MicroformatData';

  url_canonical: string;
  title: string;
  description: string;
  thumbnail: Thumbnail[];
  site_name: string;
  app_name: string;
  android_package: string;
  ios_app_store_id: string;
  ios_app_arguments: string;
  og_type: string;
  url_applinks_web: string;
  url_applinks_ios: string;
  url_applinks_android: string;
  url_twitter_ios: string;
  url_twitter_android: string;
  twitter_card_type: string;
  twitter_site_handle: string;
  schema_dot_org_type: string;
  noindex: string;
  is_unlisted: boolean;
  is_family_safe: boolean;
  tags: string[];
  available_countries: string[];

  constructor(data: RawNode) {
    super();
    this.url_canonical = data.urlCanonical;
    this.title = data.title;
    this.description = data.description;
    this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
    this.site_name = data.siteName;
    this.app_name = data.appName;
    this.android_package = data.androidPackage;
    this.ios_app_store_id = data.iosAppStoreId;
    this.ios_app_arguments = data.iosAppArguments;
    this.og_type = data.ogType;
    this.url_applinks_web = data.urlApplinksWeb;
    this.url_applinks_ios = data.urlApplinksIos;
    this.url_applinks_android = data.urlApplinksAndroid;
    this.url_twitter_ios = data.urlTwitterIos;
    this.url_twitter_android = data.urlTwitterAndroid;
    this.twitter_card_type = data.twitterCardType;
    this.twitter_site_handle = data.twitterSiteHandle;
    this.schema_dot_org_type = data.schemaDotOrgType;
    this.noindex = data.noindex;
    this.is_unlisted = data.unlisted;
    this.is_family_safe = data.familySafe;
    this.tags = data.tags;
    this.available_countries = data.availableCountries;
    // XXX: linkAlternatives?
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/Mix.ts:

import type { RawNode } from '../index.js';
import Playlist from './Playlist.js';

export default class Mix extends Playlist {
  static type = 'Mix';

  constructor(data: RawNode) {
    super(data);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ModalWithTitleAndButton.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Button from './Button.js';
import Text from './misc/Text.js';

export default class ModalWithTitleAndButton extends YTNode {
  static type = 'ModalWithTitleAndButton';

  title: Text;
  content: Text;
  button: Button | null;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.content = new Text(data.content);
    this.button = Parser.parseItem(data.button, Button);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/Movie.ts:

import { timeToSeconds } from '../../utils/Utils.js';
import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Menu from './menus/Menu.js';
import Author from './misc/Author.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';

export default class Movie extends YTNode {
  static type = 'Movie';

  id: string;
  title: Text;
  description_snippet?: Text;
  top_metadata_items: Text;
  thumbnails: Thumbnail[];
  thumbnail_overlays: ObservedArray<YTNode>;
  author: Author;

  duration: {
    text: string;
    seconds: number;
  };

  endpoint: NavigationEndpoint;
  badges: ObservedArray<YTNode>;
  use_vertical_poster: boolean;
  show_action_menu: boolean;
  menu: Menu | null;

  constructor(data: RawNode) {
    super();
    const overlay_time_status = data.thumbnailOverlays
      .find((overlay: RawNode) => overlay.thumbnailOverlayTimeStatusRenderer)
      ?.thumbnailOverlayTimeStatusRenderer.text || 'N/A';

    this.id = data.videoId;
    this.title = new Text(data.title);

    if (Reflect.has(data, 'descriptionSnippet')) {
      this.description_snippet = new Text(data.descriptionSnippet);
    }

    this.top_metadata_items = new Text(data.topMetadataItems);
    this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
    this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);
    this.author = new Author(data.longBylineText, data.ownerBadges, data.channelThumbnailSupportedRenderers?.channelThumbnailWithLinkRenderer?.thumbnail);

    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.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    this.badges = Parser.parseArray(data.badges);
    this.use_vertical_poster = data.useVerticalPoster;
    this.show_action_menu = data.showActionMenu;
    this.menu = Parser.parseItem(data.menu, Menu);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MovingThumbnail.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Thumbnail from './misc/Thumbnail.js';

export default class MovingThumbnail extends YTNode {
  static type = 'MovingThumbnail';

  constructor(data: RawNode) {
    super();
    return data.movingThumbnailDetails?.thumbnails.map((thumbnail: RawNode) => new Thumbnail(thumbnail)).sort((a: any, b: any) => b.width - a.width);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MultiMarkersPlayerBar.ts:

import { YTNode, observe, type ObservedArray } from '../helpers.js';
import type { RawNode } from '../index.js';
import { Parser } from '../index.js';
import Chapter from './Chapter.js';
import Heatmap from './Heatmap.js';

export class Marker extends YTNode {
  static type = 'Marker';

  marker_key: string;
  value: {
    heatmap?: Heatmap | null;
    chapters?: Chapter[];
  };

  constructor(data: RawNode) {
    super();
    this.marker_key = data.key;

    this.value = {};

    if (Reflect.has(data, 'value')) {
      if (Reflect.has(data.value, 'heatmap')) {
        this.value.heatmap = Parser.parseItem(data.value.heatmap, Heatmap);
      }

      if (Reflect.has(data.value, 'chapters')) {
        this.value.chapters = Parser.parseArray(data.value.chapters, Chapter);
      }
    }
  }
}

export default class MultiMarkersPlayerBar extends YTNode {
  static type = 'MultiMarkersPlayerBar';

  markers_map: ObservedArray<Marker>;

  constructor(data: RawNode) {
    super();
    this.markers_map = observe(data.markersMap?.map((marker: {
      key: string;
      value: {
        [key: string]: RawNode
      }
    }) => new Marker(marker)) || []);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicCardShelf.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Button from './Button.js';
import Menu from './menus/Menu.js';
import Text from './misc/Text.js';
import MusicCardShelfHeaderBasic from './MusicCardShelfHeaderBasic.js';
import MusicInlineBadge from './MusicInlineBadge.js';
import MusicItemThumbnailOverlay from './MusicItemThumbnailOverlay.js';
import MusicThumbnail from './MusicThumbnail.js';
import NavigationEndpoint from './NavigationEndpoint.js';

export default class MusicCardShelf extends YTNode {
  static type = 'MusicCardShelf';

  thumbnail: MusicThumbnail | null;
  title: Text;
  subtitle: Text;
  buttons: ObservedArray<Button>;
  menu: Menu | null;
  on_tap: NavigationEndpoint;
  header: MusicCardShelfHeaderBasic | null;
  end_icon_type?: string;
  subtitle_badges: ObservedArray<MusicInlineBadge>;
  thumbnail_overlay: MusicItemThumbnailOverlay | null;
  contents?: ObservedArray<YTNode>;

  constructor(data: RawNode) {
    super();
    this.thumbnail = Parser.parseItem(data.thumbnail, MusicThumbnail);
    this.title = new Text(data.title);
    this.subtitle = new Text(data.subtitle);
    this.buttons = Parser.parseArray(data.buttons, Button);
    this.menu = Parser.parseItem(data.menu, Menu);
    this.on_tap = new NavigationEndpoint(data.onTap);
    this.header = Parser.parseItem(data.header, MusicCardShelfHeaderBasic);

    if (Reflect.has(data, 'endIcon') && Reflect.has(data.endIcon, 'iconType')) {
      this.end_icon_type = data.endIcon.iconType;
    }

    this.subtitle_badges = Parser.parseArray(data.subtitleBadges, MusicInlineBadge);
    this.thumbnail_overlay = Parser.parseItem(data.thumbnailOverlay, MusicItemThumbnailOverlay);

    if (Reflect.has(data, 'contents')) {
      this.contents = Parser.parseArray(data.contents);
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicCardShelfHeaderBasic.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Text from './misc/Text.js';

export default class MusicCardShelfHeaderBasic extends YTNode {
  static type = 'MusicCardShelfHeaderBasic';

  title: Text;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicCarouselShelf.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';

import MusicCarouselShelfBasicHeader from './MusicCarouselShelfBasicHeader.js';
import MusicMultiRowListItem from './MusicMultiRowListItem.js';
import MusicNavigationButton from './MusicNavigationButton.js';
import MusicResponsiveListItem from './MusicResponsiveListItem.js';
import MusicTwoRowItem from './MusicTwoRowItem.js';

export default class MusicCarouselShelf extends YTNode {
  static type = 'MusicCarouselShelf';

  header: MusicCarouselShelfBasicHeader | null;
  contents: ObservedArray<MusicTwoRowItem | MusicResponsiveListItem | MusicMultiRowListItem | MusicNavigationButton>;
  num_items_per_column?: number;

  constructor(data: RawNode) {
    super();
    this.header = Parser.parseItem(data.header, MusicCarouselShelfBasicHeader);
    this.contents = Parser.parseArray(data.contents, [ MusicTwoRowItem, MusicResponsiveListItem, MusicMultiRowListItem, MusicNavigationButton ]);

    if (Reflect.has(data, 'numItemsPerColumn')) {
      this.num_items_per_column = parseInt(data.numItemsPerColumn);
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicCarouselShelfBasicHeader.ts:

import { type ObservedArray, YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Button from './Button.js';
import IconLink from './IconLink.js';
import MusicThumbnail from './MusicThumbnail.js';
import Text from './misc/Text.js';

export default class MusicCarouselShelfBasicHeader extends YTNode {
  static type = 'MusicCarouselShelfBasicHeader';

  title: Text;
  strapline?: Text;
  thumbnail?: MusicThumbnail | null;
  more_content?: Button | null;
  end_icons?: ObservedArray<IconLink>;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);

    if (Reflect.has(data, 'strapline')) {
      this.strapline = new Text(data.strapline);
    }

    if (Reflect.has(data, 'thumbnail')) {
      this.thumbnail = Parser.parseItem(data.thumbnail, MusicThumbnail);
    }

    if (Reflect.has(data, 'moreContentButton')) {
      this.more_content = Parser.parseItem(data.moreContentButton, Button);
    }

    if (Reflect.has(data, 'endIcons')) {
      this.end_icons = Parser.parseArray(data.endIcons, IconLink);
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicDescriptionShelf.ts:

import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class MusicDescriptionShelf extends YTNode {
  static type = 'MusicDescriptionShelf';

  description: Text;
  max_collapsed_lines?: string;
  max_expanded_lines?: string;
  footer: Text;

  constructor(data: RawNode) {
    super();
    this.description = new Text(data.description);

    if (Reflect.has(data, 'maxCollapsedLines')) {
      this.max_collapsed_lines = data.maxCollapsedLines;
    }

    if (Reflect.has(data, 'maxExpandedLines')) {
      this.max_expanded_lines = data.maxExpandedLines;
    }

    this.footer = new Text(data.footer);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicDetailHeader.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import type NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';
import type TextRun from './misc/TextRun.js';
import Thumbnail from './misc/Thumbnail.js';

export default class MusicDetailHeader extends YTNode {
  static type = 'MusicDetailHeader';

  title: Text;
  description: Text;
  subtitle: Text;
  second_subtitle: Text;
  year: string;
  song_count: string;
  total_duration: string;
  thumbnails: Thumbnail[];
  badges: ObservedArray<YTNode>;
  author?: {
    name: string;
    channel_id: string | undefined;
    endpoint: NavigationEndpoint | undefined;
  };
  menu: YTNode;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.description = new Text(data.description);
    this.subtitle = new Text(data.subtitle);
    this.second_subtitle = new Text(data.secondSubtitle);
    this.year = this.subtitle.runs?.find((run) => (/^[12][0-9]{3}$/).test(run.text))?.text || '';
    this.song_count = this.second_subtitle.runs?.[0]?.text || '';
    this.total_duration = this.second_subtitle.runs?.[2]?.text || '';
    this.thumbnails = Thumbnail.fromResponse(data.thumbnail.croppedSquareThumbnailRenderer.thumbnail);
    this.badges = Parser.parseArray(data.subtitleBadges);

    const author = this.subtitle.runs?.find((run) => (run as TextRun)?.endpoint?.payload?.browseId.startsWith('UC'));

    if (author) {
      this.author = {
        name: (author as TextRun).text,
        channel_id: (author as TextRun).endpoint?.payload?.browseId,
        endpoint: (author as TextRun).endpoint
      };
    }

    this.menu = Parser.parseItem(data.menu);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicDownloadStateBadge.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class MusicDownloadStateBadge extends YTNode {
  static type = 'MusicDownloadStateBadge';

  playlist_id: string;
  supported_download_states: string[];

  constructor(data: RawNode) {
    super();
    this.playlist_id = data.playlistId;
    this.supported_download_states = data.supportedDownloadStates;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicEditablePlaylistDetailHeader.ts:

import { Parser, type RawNode } from '../index.js';
import { YTNode } from '../helpers.js';

export default class MusicEditablePlaylistDetailHeader extends YTNode {
  static type = 'MusicEditablePlaylistDetailHeader';

  header: YTNode;
  edit_header: YTNode;
  playlist_id: string;

  constructor(data: RawNode) {
    super();
    this.header = Parser.parseItem(data.header);
    this.edit_header = Parser.parseItem(data.editHeader);
    this.playlist_id = data.playlistId;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicElementHeader.ts:

import { Parser, type RawNode } from '../index.js';
import Element from './Element.js';
import { YTNode } from '../helpers.js';

export default class MusicElementHeader extends YTNode {
  static type = 'MusicElementHeader';

  element: Element | null;

  constructor(data: RawNode) {
    super();
    this.element = Reflect.has(data, 'elementRenderer') ? Parser.parseItem(data, Element) : null;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicHeader.ts:

import { Parser, type RawNode } from '../index.js';
import { YTNode } from '../helpers.js';
import Text from './misc/Text.js';

export default class MusicHeader extends YTNode {
  static type = 'MusicHeader';

  header?: YTNode;
  title?: Text;

  constructor(data: RawNode) {
    super();

    if (Reflect.has(data, 'header')) {
      this.header = Parser.parseItem(data.header);
    }

    if (Reflect.has(data, 'title')) {
      this.title = new Text(data.title);
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicImmersiveHeader.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import MusicThumbnail from './MusicThumbnail.js';
import Text from './misc/Text.js';

export default class MusicImmersiveHeader extends YTNode {
  static type = 'MusicImmersiveHeader';

  title: Text;
  description: Text;
  thumbnail: MusicThumbnail | null;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.description = new Text(data.description);
    this.thumbnail = Parser.parseItem(data.thumbnail, MusicThumbnail);
    /**
         Not useful for now.
         this.menu = Parser.parse(data.menu);
         this.play_button = Parser.parse(data.playButton);
         this.start_radio_button = Parser.parse(data.startRadioButton);
     */
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicInlineBadge.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class MusicInlineBadge extends YTNode {
  static type = 'MusicInlineBadge';

  icon_type: string;
  label: string;

  constructor(data: RawNode) {
    super();
    this.icon_type = data.icon.iconType;
    this.label = data.accessibilityData.accessibilityData.label;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicItemThumbnailOverlay.ts:

import { Parser, type RawNode } from '../index.js';
import MusicPlayButton from './MusicPlayButton.js';
import { YTNode } from '../helpers.js';

export default class MusicItemThumbnailOverlay extends YTNode {
  static type = 'MusicItemThumbnailOverlay';

  content: MusicPlayButton | null;
  content_position: string;
  display_style: string;

  constructor(data: RawNode) {
    super();
    this.content = Parser.parseItem(data.content, MusicPlayButton);
    this.content_position = data.contentPosition;
    this.display_style = data.displayStyle;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicLargeCardItemCarousel.ts:

import NavigationEndpoint from './NavigationEndpoint.js';
import Thumbnail from './misc/Thumbnail.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

class ActionButton {
  static type = 'ActionButton';

  icon_name: string;
  endpoint: NavigationEndpoint;
  a11y_text: string;
  style: string;

  constructor(data: RawNode) {
    this.icon_name = data.iconName;
    this.endpoint = new NavigationEndpoint(data.onTap);
    this.a11y_text = data.a11yText;
    this.style = data.style;
  }
}

class Panel {
  static type = 'Panel';

  image: Thumbnail[];

  content_mode: string;
  crop_options: string;
  image_aspect_ratio: string;
  caption: string;
  action_buttons: ActionButton[];

  constructor (data: RawNode) {
    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;
    this.caption = data.caption;
    this.action_buttons = data.actionButtons.map((el: RawNode) => new ActionButton(el));
  }
}

export default class MusicLargeCardItemCarousel extends YTNode {
  static type = 'MusicLargeCardItemCarousel';

  panels: Panel[];
  header;

  constructor(data: RawNode) {
    super();
    // TODO: check this
    this.header = data.shelf.header;
    this.panels = data.shelf.panels.map((el: RawNode) => new Panel(el));
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicMultiRowListItem.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import { Text } from '../misc.js';

import Menu from './menus/Menu.js';
import MusicItemThumbnailOverlay from './MusicItemThumbnailOverlay.js';
import MusicThumbnail from './MusicThumbnail.js';
import NavigationEndpoint from './NavigationEndpoint.js';

export default class MusicMultiRowListItem extends YTNode {
  static type = 'MusicMultiRowListItem';

  thumbnail: MusicThumbnail | null;
  overlay: MusicItemThumbnailOverlay | null;
  on_tap: NavigationEndpoint;
  menu: Menu | null;
  subtitle: Text;
  title: Text;
  second_title?: Text;
  description?: Text;
  display_style?: string;

  constructor(data: RawNode) {
    super();
    this.thumbnail = Parser.parseItem(data.thumbnail, MusicThumbnail);
    this.overlay = Parser.parseItem(data.overlay, MusicItemThumbnailOverlay);
    this.on_tap = new NavigationEndpoint(data.onTap);
    this.menu = Parser.parseItem(data.menu, Menu);
    this.subtitle = new Text(data.subtitle);
    this.title = new Text(data.title);

    if (Reflect.has(data, 'secondTitle')) {
      this.second_title = new Text(data.secondTitle);
    }

    if (Reflect.has(data, 'description')) {
      this.description = new Text(data.description);
    }

    if (Reflect.has(data, 'displayStyle')) {
      this.display_style = data.displayStyle;
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicNavigationButton.ts:

import Text from './misc/Text.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class MusicNavigationButton extends YTNode {
  static type = 'MusicNavigationButton';

  button_text: string;
  endpoint: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.button_text = new Text(data.buttonText).toString();
    this.endpoint = new NavigationEndpoint(data.clickCommand);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicPlayButton.ts:

import NavigationEndpoint from './NavigationEndpoint.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class MusicPlayButton extends YTNode {
  static type = 'MusicPlayButton';

  endpoint: NavigationEndpoint;
  play_icon_type: string;
  pause_icon_type: string;
  play_label?: string;
  pause_label?: string;
  icon_color: string;

  constructor(data: RawNode) {
    super();
    this.endpoint = new NavigationEndpoint(data.playNavigationEndpoint);
    this.play_icon_type = data.playIcon.iconType;
    this.pause_icon_type = data.pauseIcon.iconType;

    if (Reflect.has(data, 'accessibilityPlayData')) {
      this.play_label = data.accessibilityPlayData.accessibilityData?.label;
    }

    if (Reflect.has(data, 'accessibilityPauseData')) {
      this.pause_label = data.accessibilityPauseData.accessibilityData?.label;
    }

    this.icon_color = data.iconColor;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicPlaylistEditHeader.ts:

import { Parser, type RawNode } from '../index.js';
import { YTNode } from '../helpers.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Dropdown from './Dropdown.js';
import Text from './misc/Text.js';

export default class MusicPlaylistEditHeader extends YTNode {
  static type = 'MusicPlaylistEditHeader';

  title: Text;
  edit_title: Text;
  edit_description: Text;
  privacy: string;
  playlist_id: string;
  endpoint: NavigationEndpoint;
  privacy_dropdown: Dropdown | null;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.edit_title = new Text(data.editTitle);
    this.edit_description = new Text(data.editDescription);
    this.privacy = data.privacy;
    this.playlist_id = data.playlistId;
    this.endpoint = new NavigationEndpoint(data.collaborationSettingsCommand);
    this.privacy_dropdown = Parser.parseItem(data.privacyDropdown, Dropdown);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicPlaylistShelf.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import MusicResponsiveListItem from './MusicResponsiveListItem.js';

export default class MusicPlaylistShelf extends YTNode {
  static type = 'MusicPlaylistShelf';

  playlist_id: string;
  contents: ObservedArray<MusicResponsiveListItem>;
  collapsed_item_count: number;
  continuation: string | null;

  constructor(data: RawNode) {
    super();
    this.playlist_id = data.playlistId;
    this.contents = Parser.parseArray(data.contents, MusicResponsiveListItem);
    this.collapsed_item_count = data.collapsedItemCount;
    this.continuation = data.continuations?.[0]?.nextContinuationData?.continuation || null;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicQueue.ts:

import { Parser, type RawNode } from '../index.js';
import PlaylistPanel from './PlaylistPanel.js';
import { YTNode } from '../helpers.js';

export default class MusicQueue extends YTNode {
  static type = 'MusicQueue';

  content: PlaylistPanel | null;

  constructor(data: RawNode) {
    super();
    this.content = Parser.parseItem(data.content, PlaylistPanel);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicResponsiveHeader.ts:

import { Parser, type RawNode } from '../index.js';
import { YTNode } from '../helpers.js';
import MusicThumbnail from './MusicThumbnail.js';
import MusicDescriptionShelf from './MusicDescriptionShelf.js';
import MusicInlineBadge from './MusicInlineBadge.js';
import MusicPlayButton from './MusicPlayButton.js';
import ToggleButton from './ToggleButton.js';
import Menu from './menus/Menu.js';
import Text from './misc/Text.js';
import Button from './Button.js';
import DownloadButton from './DownloadButton.js';

import type { ObservedArray } from '../helpers.js';

export default class MusicResponsiveHeader extends YTNode {
  static type = 'MusicResponsiveHeader';

  thumbnail: MusicThumbnail | null;
  buttons: ObservedArray<DownloadButton | ToggleButton | MusicPlayButton | Button | Menu>;
  title: Text;
  subtitle: Text;
  strapline_text_one: Text;
  strapline_thumbnail: MusicThumbnail | null;
  second_subtitle: Text;
  subtitle_badge?: ObservedArray<MusicInlineBadge> | null;
  description?: MusicDescriptionShelf | null;

  constructor(data: RawNode) {
    super();
    this.thumbnail = Parser.parseItem(data.thumbnail, MusicThumbnail);
    this.buttons = Parser.parseArray(data.buttons, [ DownloadButton, ToggleButton, MusicPlayButton, Button, Menu ]);
    this.title = new Text(data.title);
    this.subtitle = new Text(data.subtitle);
    this.strapline_text_one = new Text(data.straplineTextOne);
    this.strapline_thumbnail = Parser.parseItem(data.straplineThumbnail, MusicThumbnail);
    this.second_subtitle = new Text(data.secondSubtitle);

    if (Reflect.has(data, 'subtitleBadge')) {
      this.subtitle_badge = Parser.parseArray(data.subtitleBadge, MusicInlineBadge);
    }

    if (Reflect.has(data, 'description')) {
      this.description = Parser.parseItem(data.description, MusicDescriptionShelf);
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicResponsiveListItem.ts:

// TODO: Clean up and refactor this.

import { YTNode } from '../helpers.js';
import { isTextRun, timeToSeconds } from '../../utils/Utils.js';
import type { ObservedArray } from '../helpers.js';
import type { RawNode } from '../index.js';
import type TextRun from './misc/TextRun.js';

import { Parser } from '../index.js';
import MusicItemThumbnailOverlay from './MusicItemThumbnailOverlay.js';
import MusicResponsiveListItemFixedColumn from './MusicResponsiveListItemFixedColumn.js';
import MusicResponsiveListItemFlexColumn from './MusicResponsiveListItemFlexColumn.js';
import MusicThumbnail from './MusicThumbnail.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Menu from './menus/Menu.js';
import Text from './misc/Text.js';

export default class MusicResponsiveListItem extends YTNode {
  static type = 'MusicResponsiveListItem';

  flex_columns: ObservedArray<MusicResponsiveListItemFlexColumn>;
  fixed_columns: ObservedArray<MusicResponsiveListItemFixedColumn>;
  #playlist_item_data: {
    video_id: string;
    playlist_set_video_id: string;
  };

  endpoint?: NavigationEndpoint;
  item_type: 'album' | 'playlist' | 'artist' | 'library_artist' | 'non_music_track' | 'video' | 'song' | 'endpoint' | 'unknown' | 'podcast_show' | undefined;
  index?: Text;
  thumbnail?: MusicThumbnail | null;
  badges;
  menu?: Menu | null;
  overlay?: MusicItemThumbnailOverlay | null;

  id?: string;
  title?: string;
  duration?: {
    text: string;
    seconds: number;
  };

  album?: {
    id?: string,
    name: string,
    endpoint?: NavigationEndpoint
  };

  artists?: {
    name: string,
    channel_id?: string,
    endpoint?: NavigationEndpoint
  }[];

  views?: string;
  authors?: {
    name: string,
    channel_id?: string
    endpoint?: NavigationEndpoint
  }[];

  name?: string;
  subtitle?: Text;
  subscribers?: string;
  song_count?: string;

  // TODO: these might be replaceable with Author class
  author?: {
    name: string,
    channel_id?: string
    endpoint?: NavigationEndpoint
  };
  item_count?: string;
  year?: string;

  constructor(data: RawNode) {
    super();
    this.flex_columns = Parser.parseArray(data.flexColumns, MusicResponsiveListItemFlexColumn);
    this.fixed_columns = Parser.parseArray(data.fixedColumns, MusicResponsiveListItemFixedColumn);

    this.#playlist_item_data = {
      video_id: data?.playlistItemData?.videoId || null,
      playlist_set_video_id: data?.playlistItemData?.playlistSetVideoId || null
    };

    if (Reflect.has(data, 'navigationEndpoint')) {
      this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    }

    let page_type = this.endpoint?.payload?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType;

    if (!page_type) {
      const is_non_music_track = this.flex_columns.find(
        (col) => col.title.endpoint?.payload?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType === 'MUSIC_PAGE_TYPE_NON_MUSIC_AUDIO_TRACK_PAGE'
      );

      if (is_non_music_track) {
        page_type = 'MUSIC_PAGE_TYPE_NON_MUSIC_AUDIO_TRACK_PAGE';
      }
    }

    switch (page_type) {
      case 'MUSIC_PAGE_TYPE_ALBUM':
        this.item_type = 'album';
        this.#parseAlbum();
        break;
      case 'MUSIC_PAGE_TYPE_PLAYLIST':
        this.item_type = 'playlist';
        this.#parsePlaylist();
        break;
      case 'MUSIC_PAGE_TYPE_ARTIST':
      case 'MUSIC_PAGE_TYPE_USER_CHANNEL':
        this.item_type = 'artist';
        this.#parseArtist();
        break;
      case 'MUSIC_PAGE_TYPE_LIBRARY_ARTIST':
        this.item_type = 'library_artist';
        this.#parseLibraryArtist();
        break;
      case 'MUSIC_PAGE_TYPE_NON_MUSIC_AUDIO_TRACK_PAGE':
        this.item_type = 'non_music_track';
        this.#parseNonMusicTrack();
        break;
      case 'MUSIC_PAGE_TYPE_PODCAST_SHOW_DETAIL_PAGE':
        this.item_type = 'podcast_show';
        this.#parsePodcastShow();
        break;
      default:
        if (this.flex_columns[1]) {
          this.#parseVideoOrSong();
        } else {
          this.#parseOther();
        }
    }

    if (Reflect.has(data, 'index')) {
      this.index = new Text(data.index);
    }

    if (Reflect.has(data, 'thumbnail')) {
      this.thumbnail = Parser.parseItem(data.thumbnail, MusicThumbnail);
    }

    if (Reflect.has(data, 'badges')) {
      this.badges = Parser.parseArray(data.badges);
    }

    if (Reflect.has(data, 'menu')) {
      this.menu = Parser.parseItem(data.menu, Menu);
    }

    if (Reflect.has(data, 'overlay')) {
      this.overlay = Parser.parseItem(data.overlay, MusicItemThumbnailOverlay);
    }
  }

  #parseOther() {
    this.title = this.flex_columns.first().title.toString();

    if (this.endpoint) {
      this.item_type = 'endpoint';
    } else {
      this.item_type = 'unknown';
    }
  }

  #parseVideoOrSong() {
    const music_video_type = (this.flex_columns.at(0)?.title.runs?.at(0) as TextRun)?.endpoint?.payload?.watchEndpointMusicSupportedConfigs?.watchEndpointMusicConfig?.musicVideoType;
    switch (music_video_type) {
      case 'MUSIC_VIDEO_TYPE_UGC':
      case 'MUSIC_VIDEO_TYPE_OMV':
        this.item_type = 'video';
        this.#parseVideo();
        break;
      case 'MUSIC_VIDEO_TYPE_ATV':
        this.item_type = 'song';
        this.#parseSong();
        break;
      default:
        this.#parseOther();
    }
  }

  #parseSong() {
    this.id = this.#playlist_item_data.video_id || this.endpoint?.payload?.videoId;
    this.title = this.flex_columns.first().title.toString();

    const duration_text = this.flex_columns.at(1)?.title.runs?.find(
      (run) => (/^\d+$/).test(run.text.replace(/:/g, '')))?.text || this.fixed_columns.first()?.title?.toString();

    if (duration_text) {
      this.duration = {
        text: duration_text,
        seconds: timeToSeconds(duration_text)
      };
    }

    const album_run =
      this.flex_columns.at(1)?.title.runs?.find(
        (run) =>
          (isTextRun(run) && run.endpoint) &&
          run.endpoint.payload.browseId.startsWith('MPR')
      ) ||
      this.flex_columns.at(2)?.title.runs?.find(
        (run) =>
          (isTextRun(run) && run.endpoint) &&
          run.endpoint.payload.browseId.startsWith('MPR')
      );

    if (album_run && isTextRun(album_run)) {
      this.album = {
        id: album_run.endpoint?.payload?.browseId,
        name: album_run.text,
        endpoint: album_run.endpoint
      };
    }

    const artist_runs = this.flex_columns.at(1)?.title.runs?.filter(
      (run) => (isTextRun(run) && run.endpoint) && run.endpoint.payload.browseId.startsWith('UC')
    );

    if (artist_runs) {
      this.artists = artist_runs.map((run) => ({
        name: run.text,
        channel_id: isTextRun(run) ? run.endpoint?.payload?.browseId : undefined,
        endpoint: isTextRun(run) ? run.endpoint : undefined
      }));
    }
  }

  #parseVideo() {
    this.id = this.#playlist_item_data.video_id;
    this.title = this.flex_columns.first().title.toString();
    this.views = this.flex_columns.at(1)?.title.runs?.find((run) => run.text.match(/(.*?) views/))?.toString();

    const author_runs = this.flex_columns.at(1)?.title.runs?.filter(
      (run) =>
        (isTextRun(run) && run.endpoint) &&
        run.endpoint.payload.browseId.startsWith('UC')
    );

    if (author_runs) {
      this.authors = author_runs.map((run) => {
        return {
          name: run.text,
          channel_id: isTextRun(run) ? run.endpoint?.payload?.browseId : undefined,
          endpoint: isTextRun(run) ? run.endpoint : undefined
        };
      });
    }

    const duration_text = this.flex_columns[1].title.runs?.find(
      (run) => (/^\d+$/).test(run.text.replace(/:/g, '')))?.text || this.fixed_columns.first()?.title.runs?.find((run) => (/^\d+$/).test(run.text.replace(/:/g, '')))?.text;

    if (duration_text) {
      this.duration = {
        text: duration_text,
        seconds: timeToSeconds(duration_text)
      };
    }
  }

  #parseArtist() {
    this.id = this.endpoint?.payload?.browseId;
    this.name = this.flex_columns.first().title.toString();
    this.subtitle = this.flex_columns.at(1)?.title;
    this.subscribers = this.subtitle?.runs?.find((run) => (/^(\d*\.)?\d+[M|K]? subscribers?$/i).test(run.text))?.text || '';
  }

  #parseLibraryArtist() {
    this.name = this.flex_columns.first().title.toString();
    this.subtitle = this.flex_columns.at(1)?.title;
    this.song_count = this.subtitle?.runs?.find((run) => (/^\d+(,\d+)? songs?$/i).test(run.text))?.text || '';
  }

  #parseNonMusicTrack() {
    this.id = this.#playlist_item_data.video_id || this.endpoint?.payload?.videoId;
    this.title = this.flex_columns.first().title.toString();
  }

  #parsePodcastShow() {
    this.id = this.endpoint?.payload?.browseId;
    this.title = this.flex_columns.first().title.toString();
  }

  #parseAlbum() {
    this.id = this.endpoint?.payload?.browseId;
    this.title = this.flex_columns.first().title.toString();

    const author_run = this.flex_columns.at(1)?.title.runs?.find(
      (run) =>
        (isTextRun(run) && run.endpoint) &&
        run.endpoint.payload.browseId.startsWith('UC')
    );

    if (author_run && isTextRun(author_run)) {
      this.author = {
        name: author_run.text,
        channel_id: author_run.endpoint?.payload?.browseId,
        endpoint: author_run.endpoint
      };
    }

    this.year = this.flex_columns.at(1)?.title.runs?.find(
      (run) => (/^[12][0-9]{3}$/).test(run.text)
    )?.text;
  }

  #parsePlaylist() {
    this.id = this.endpoint?.payload?.browseId;
    this.title = this.flex_columns.first().title.toString();

    const item_count_run = this.flex_columns.at(1)?.title
      .runs?.find((run) => run.text.match(/\d+ (song|songs)/));

    this.item_count = item_count_run ? item_count_run.text : undefined;

    const author_run = this.flex_columns.at(1)?.title.runs?.find(
      (run) =>
        (isTextRun(run) && run.endpoint) &&
        run.endpoint.payload.browseId.startsWith('UC')
    );

    if (author_run && isTextRun(author_run)) {
      this.author = {
        name: author_run.text,
        channel_id: author_run.endpoint?.payload?.browseId,
        endpoint: author_run.endpoint
      };
    }
  }

  get thumbnails() {
    return this.thumbnail?.contents || [];
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicResponsiveListItemFixedColumn.ts:

import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class MusicResponsiveListItemFixedColumn extends YTNode {
  static type = 'musicResponsiveListItemFlexColumnRenderer';

  title: Text;
  display_priority: string;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.text);
    this.display_priority = data.displayPriority;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicResponsiveListItemFlexColumn.ts:

import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class MusicResponsiveListItemFlexColumn extends YTNode {
  static type = 'MusicResponsiveListItemFlexColumn';

  title: Text;
  display_priority: string;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.text);
    this.display_priority = data.displayPriority;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicShelf.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Button from './Button.js';
import MusicResponsiveListItem from './MusicResponsiveListItem.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';

export default class MusicShelf extends YTNode {
  static type = 'MusicShelf';

  title: Text;
  contents: ObservedArray<MusicResponsiveListItem>;
  endpoint?: NavigationEndpoint;
  continuation?: string;
  bottom_text?: Text;
  bottom_button?: Button | null;
  subheaders?: ObservedArray<YTNode>;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.contents = Parser.parseArray(data.contents, MusicResponsiveListItem);

    if (Reflect.has(data, 'bottomEndpoint')) {
      this.endpoint = new NavigationEndpoint(data.bottomEndpoint);
    }

    if (Reflect.has(data, 'continuations')) {
      this.continuation =
        data.continuations?.[0].nextContinuationData?.continuation ||
        data.continuations?.[0].reloadContinuationData?.continuation;
    }

    if (Reflect.has(data, 'bottomText')) {
      this.bottom_text = new Text(data.bottomText);
    }

    if (Reflect.has(data, 'bottomButton')) {
      this.bottom_button = Parser.parseItem(data.bottomButton, Button);
    }

    if (Reflect.has(data, 'subheaders')) {
      this.subheaders = Parser.parseArray(data.subheaders);
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicSideAlignedItem.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';

export default class MusicSideAlignedItem extends YTNode {
  static type = 'MusicSideAlignedItem';

  start_items?: ObservedArray<YTNode>;
  end_items?: ObservedArray<YTNode>;

  constructor(data: RawNode) {
    super();
    if (Reflect.has(data, 'startItems')) {
      this.start_items = Parser.parseArray(data.startItems);
    }

    if (Reflect.has(data, 'endItems')) {
      this.end_items = Parser.parseArray(data.endItems);
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicSortFilterButton.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import MusicMultiSelectMenu from './menus/MusicMultiSelectMenu.js';
import Text from './misc/Text.js';

export default class MusicSortFilterButton extends YTNode {
  static type = 'MusicSortFilterButton';

  title: string;
  icon_type?: string;
  menu: MusicMultiSelectMenu | null;

  constructor(data: RawNode) {
    super();

    this.title = new Text(data.title).toString();

    if (Reflect.has(data, 'icon')) {
      this.icon_type = data.icon.iconType;
    }

    this.menu = Parser.parseItem(data.menu, MusicMultiSelectMenu);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicTastebuilderShelf.ts:

import { Parser } from '../index.js';
import Button from './Button.js';
import Text from './misc/Text.js';
import MusicTastebuilderShelfThumbnail from './MusicTastebuilderShelfThumbnail.js';

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class MusicTasteBuilderShelf extends YTNode {
  static type = 'MusicTasteBuilderShelf';

  thumbnail: MusicTastebuilderShelfThumbnail | null;
  primary_text: Text;
  secondary_text: Text;
  action_button: Button | null;
  is_visible: boolean;

  constructor(data: RawNode) {
    super();
    this.thumbnail = Parser.parseItem(data.thumbnail, MusicTastebuilderShelfThumbnail);
    this.primary_text = new Text(data.primaryText);
    this.secondary_text = new Text(data.secondaryText);
    this.action_button = Parser.parseItem(data.actionButton, Button);
    this.is_visible = data.isVisible;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicTastebuilderShelfThumbnail.ts:

import { YTNode } from '../helpers.js';
import { Thumbnail } from '../misc.js';
import type { RawNode } from '../index.js';

export default class MusicTastebuilderShelfThumbnail extends YTNode {
  static type = 'MusicTastebuilderShelfThumbnail';

  thumbnail: Thumbnail[];

  constructor(data: RawNode) {
    super();
    this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicThumbnail.ts:

import Thumbnail from './misc/Thumbnail.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class MusicThumbnail extends YTNode {
  static type = 'MusicThumbnail';

  contents: Thumbnail[];

  constructor(data: RawNode) {
    super();
    this.contents = Thumbnail.fromResponse(data.thumbnail);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicTwoRowItem.ts:

// TODO: Refactor this.

import { YTNode, type SuperParsedResult } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import MusicItemThumbnailOverlay from './MusicItemThumbnailOverlay.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Menu from './menus/Menu.js';
import Text from './misc/Text.js';
import type TextRun from './misc/TextRun.js';
import Thumbnail from './misc/Thumbnail.js';

export default class MusicTwoRowItem extends YTNode {
  static type = 'MusicTwoRowItem';

  title: Text;
  endpoint: NavigationEndpoint;
  id: string | undefined;
  subtitle: Text;
  badges: SuperParsedResult<YTNode> | null;
  item_type: string;
  subscribers?: string;
  item_count?: string | null;
  year?: string;
  views?: string;

  artists?: {
    name: string;
    channel_id: string | undefined;
    endpoint: NavigationEndpoint | undefined;
  }[];

  author?: {
    name: string;
    channel_id: string | undefined;
    endpoint: NavigationEndpoint | undefined;
  };

  thumbnail: Thumbnail[];
  thumbnail_overlay: MusicItemThumbnailOverlay | null;
  menu: Menu | null;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);

    this.id =
      this.endpoint?.payload?.browseId ||
      this.endpoint?.payload?.videoId;

    this.subtitle = new Text(data.subtitle);
    this.badges = Parser.parse(data.subtitleBadges);

    const page_type = this.endpoint?.payload?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType;

    switch (page_type) {
      case 'MUSIC_PAGE_TYPE_ARTIST':
        this.item_type = 'artist';
        break;
      case 'MUSIC_PAGE_TYPE_PLAYLIST':
        this.item_type = 'playlist';
        break;
      case 'MUSIC_PAGE_TYPE_ALBUM':
        this.item_type = 'album';
        break;
      default:
        if (this.endpoint?.metadata?.api_url === '/next') {
          this.item_type = 'endpoint';
        } else if (this.subtitle.runs?.[0]) {
          if (this.subtitle.runs[0].text !== 'Song') {
            this.item_type = 'video';
          } else {
            this.item_type = 'song';
          }
        } else if (this.endpoint) {
          this.item_type = 'endpoint';
        } else {
          this.item_type = 'unknown';
        }
        break;
    }

    if (this.item_type == 'artist') {
      this.subscribers = this.subtitle.runs?.find((run) => (/^(\d*\.)?\d+[M|K]? subscribers?$/i).test(run.text))?.text || '';
    } else if (this.item_type == 'playlist') {
      const item_count_run = this.subtitle.runs?.find((run) => run.text.match(/\d+ songs|song/));
      this.item_count = item_count_run ? (item_count_run as TextRun).text : null;
    } else if (this.item_type == 'album') {
      const artists = this.subtitle.runs?.filter((run: any) => run.endpoint?.payload?.browseId.startsWith('UC'));
      if (artists) {
        this.artists = artists.map((artist: any) => ({
          name: artist.text,
          channel_id: artist.endpoint?.payload?.browseId,
          endpoint: artist.endpoint
        }));
      }
      this.year = this.subtitle.runs?.slice(-1)[0].text;
      if (isNaN(Number(this.year)))
        delete this.year;
    } else if (this.item_type == 'video') {
      this.views = this?.subtitle.runs?.find((run) => run?.text.match(/(.*?) views/))?.text || 'N/A';

      const author = this.subtitle.runs?.find((run: any) => run.endpoint?.payload?.browseId?.startsWith('UC'));
      if (author) {
        this.author = {
          name: (author as TextRun)?.text,
          channel_id: (author as TextRun)?.endpoint?.payload?.browseId,
          endpoint: (author as TextRun)?.endpoint
        };
      }
    } else if (this.item_type == 'song') {
      const artists = this.subtitle.runs?.filter((run: any) => run.endpoint?.payload?.browseId.startsWith('UC'));
      if (artists) {
        this.artists = artists.map((artist: any) => ({
          name: (artist as TextRun)?.text,
          channel_id: (artist as TextRun)?.endpoint?.payload?.browseId,
          endpoint: (artist as TextRun)?.endpoint
        }));
      }
    }

    this.thumbnail = Thumbnail.fromResponse(data.thumbnailRenderer.musicThumbnailRenderer.thumbnail);
    this.thumbnail_overlay = Parser.parseItem(data.thumbnailOverlay, MusicItemThumbnailOverlay);
    this.menu = Parser.parseItem(data.menu, Menu);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/MusicVisualHeader.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Menu from './menus/Menu.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';

export default class MusicVisualHeader extends YTNode {
  static type = 'MusicVisualHeader';

  title: Text;
  thumbnail: Thumbnail[];
  menu: Menu | null;
  foreground_thumbnail: Thumbnail[];

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.thumbnail = data.thumbnail ? Thumbnail.fromResponse(data.thumbnail.musicThumbnailRenderer?.thumbnail) : [];
    this.menu = Parser.parseItem(data.menu, Menu);
    this.foreground_thumbnail = data.foregroundThumbnail ? Thumbnail.fromResponse(data.foregroundThumbnail.musicThumbnailRenderer?.thumbnail) : [];
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/NavigationEndpoint.ts:

import type Actions from '../../core/Actions.js';
import type { ApiResponse } from '../../core/Actions.js';
import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import type { IParsedResponse } from '../types/ParsedResponse.js';
import CreatePlaylistDialog from './CreatePlaylistDialog.js';
import type ModalWithTitleAndButton from './ModalWithTitleAndButton.js';
import OpenPopupAction from './actions/OpenPopupAction.js';

export default class NavigationEndpoint extends YTNode {
  static type = 'NavigationEndpoint';

  payload;
  dialog?: CreatePlaylistDialog | YTNode | null;
  modal?: ModalWithTitleAndButton | YTNode | null;
  open_popup?: OpenPopupAction | null;

  next_endpoint?: NavigationEndpoint;

  metadata: {
    url?: string;
    api_url?: string;
    page_type?: string;
    send_post?: boolean;
  };

  constructor(data: RawNode) {
    super();

    if (data && (data.innertubeCommand || data.command))
      data = data.innertubeCommand || data.command;

    if (Reflect.has(data || {}, 'openPopupAction'))
      this.open_popup = new OpenPopupAction(data.openPopupAction);

    const name = Object.keys(data || {})
      .find((item) =>
        item.endsWith('Endpoint') ||
        item.endsWith('Command')
      );

    this.payload = name ? Reflect.get(data, name) : {};

    if (Reflect.has(this.payload, 'dialog') || Reflect.has(this.payload, 'content')) {
      this.dialog = Parser.parseItem(this.payload.dialog || this.payload.content);
    }

    if (Reflect.has(this.payload, 'modal')) {
      this.modal = Parser.parseItem(this.payload.modal);
    }

    if (Reflect.has(this.payload, 'nextEndpoint')) {
      this.next_endpoint = new NavigationEndpoint(this.payload.nextEndpoint);
    }

    if (data?.serviceEndpoint) {
      data = data.serviceEndpoint;
    }

    this.metadata = {};

    if (data?.commandMetadata?.webCommandMetadata?.url) {
      this.metadata.url = data.commandMetadata.webCommandMetadata.url;
    }

    if (data?.commandMetadata?.webCommandMetadata?.webPageType) {
      this.metadata.page_type = data.commandMetadata.webCommandMetadata.webPageType;
    }

    if (data?.commandMetadata?.webCommandMetadata?.apiUrl) {
      this.metadata.api_url = data.commandMetadata.webCommandMetadata.apiUrl.replace('/youtubei/v1/', '');
    } else if (name) {
      this.metadata.api_url = this.getEndpoint(name);
    }

    if (data?.commandMetadata?.webCommandMetadata?.sendPost) {
      this.metadata.send_post = data.commandMetadata.webCommandMetadata.sendPost;
    }

    if (data?.createPlaylistEndpoint) {
      if (data?.createPlaylistEndpoint.createPlaylistDialog) {
        this.dialog = Parser.parseItem(data?.createPlaylistEndpoint.createPlaylistDialog, CreatePlaylistDialog);
      }
    }
  }

  /**
   * Sometimes InnerTube does not return an API url, in that case the library should set it based on the name of the payload object.
   */
  getEndpoint(name: string) {
    switch (name) {
      case 'browseEndpoint':
        return '/browse';
      case 'watchEndpoint':
      case 'reelWatchEndpoint':
        return '/player';
      case 'searchEndpoint':
        return '/search';
      case 'watchPlaylistEndpoint':
        return '/next';
      case 'liveChatItemContextMenuEndpoint':
        return '/live_chat/get_item_context_menu';
    }
  }

  call<T extends IParsedResponse>(actions: Actions, args: { [key: string]: any; parse: true }): Promise<T>;
  call(actions: Actions, args?: { [key: string]: any; parse?: false }): Promise<ApiResponse>;
  call(actions: Actions, args?: { [key: string]: any; parse?: boolean }): Promise<IParsedResponse | ApiResponse> {
    if (!actions)
      throw new Error('An active caller must be provided');
    if (!this.metadata.api_url)
      throw new Error('Expected an api_url, but none was found, this is a bug.');
    return actions.execute(this.metadata.api_url, { ...this.payload, ...args });
  }

  toURL(): string | undefined {
    if (!this.metadata.url)
      return undefined;
    if (!this.metadata.page_type)
      return undefined;
    return (
      this.metadata.page_type === 'WEB_PAGE_TYPE_UNKNOWN' ?
        this.metadata.url : `https://www.youtube.com${this.metadata.url}`
    );
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/Notification.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import NavigationEndpoint from './NavigationEndpoint.js';

export default class Notification extends YTNode {
  static type = 'Notification';

  thumbnails: Thumbnail[];
  video_thumbnails: Thumbnail[];
  short_message: Text;
  sent_time: Text;
  notification_id: string;
  endpoint: NavigationEndpoint;
  record_click_endpoint: NavigationEndpoint;
  menu: YTNode;
  read: boolean;

  constructor(data: RawNode) {
    super();
    this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
    this.video_thumbnails = Thumbnail.fromResponse(data.videoThumbnail);
    this.short_message = new Text(data.shortMessage);
    this.sent_time = new Text(data.sentTimeText);
    this.notification_id = data.notificationId;
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    this.record_click_endpoint = new NavigationEndpoint(data.recordClickEndpoint);
    this.menu = Parser.parseItem(data.contextualMenu);
    this.read = data.read;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/PageHeader.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import PageHeaderView from './PageHeaderView.js';

export default class PageHeader extends YTNode {
  static type = 'PageHeader';

  page_title: string;
  content: PageHeaderView | null;

  constructor(data: RawNode) {
    super();
    this.page_title = data.pageTitle;
    this.content = Parser.parseItem(data.content, PageHeaderView);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/PageHeaderView.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import ContentMetadataView from './ContentMetadataView.js';
import ContentPreviewImageView from './ContentPreviewImageView.js';
import DecoratedAvatarView from './DecoratedAvatarView.js';
import DynamicTextView from './DynamicTextView.js';
import FlexibleActionsView from './FlexibleActionsView.js';
import DescriptionPreviewView from './DescriptionPreviewView.js';
import AttributionView from './AttributionView.js';
import ImageBannerView from './ImageBannerView.js';

export default class PageHeaderView extends YTNode {
  static type = 'PageHeaderView';

  title: DynamicTextView | null;
  image: ContentPreviewImageView | DecoratedAvatarView | null;
  metadata: ContentMetadataView | null;
  actions: FlexibleActionsView | null;
  description: DescriptionPreviewView | null;
  attributation: AttributionView | null;
  banner: ImageBannerView | null;

  constructor(data: RawNode) {
    super();
    this.title = Parser.parseItem(data.title, DynamicTextView);
    this.image = Parser.parseItem(data.image, [ ContentPreviewImageView, DecoratedAvatarView ]);
    this.metadata = Parser.parseItem(data.metadata, ContentMetadataView);
    this.actions = Parser.parseItem(data.actions, FlexibleActionsView);
    this.description = Parser.parseItem(data.description, DescriptionPreviewView);
    this.attributation = Parser.parseItem(data.attributation, AttributionView);
    this.banner = Parser.parseItem(data.banner, ImageBannerView);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/PageIntroduction.ts:

import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class PageIntroduction extends YTNode {
  static type = 'PageIntroduction';

  header_text: string;
  body_text: string;
  page_title: string;
  header_icon_type: string;

  constructor(data: RawNode) {
    super();
    this.header_text = new Text(data.headerText).toString();
    this.body_text = new Text(data.bodyText).toString();
    this.page_title = new Text(data.pageTitle).toString();
    this.header_icon_type = data.headerIcon.iconType;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/PivotButton.ts:

import { type RawNode } from '../index.js';
import { YTNode } from '../helpers.js';
import Thumbnail from './misc/Thumbnail.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';

export default class PivotButton extends YTNode {
  static type = 'PivotButton';

  thumbnail: Thumbnail[];
  endpoint: NavigationEndpoint;
  content_description: Text;
  target_id: string;
  sound_attribution_title: Text;
  waveform_animation_style: string;
  background_animation_style: string;

  constructor(data: RawNode) {
    super();
    this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
    this.endpoint = new NavigationEndpoint(data.onClickCommand);
    this.content_description = new Text(data.contentDescription);
    this.target_id = data.targetId;
    this.sound_attribution_title = new Text(data.soundAttributionTitle);
    this.waveform_animation_style = data.waveformAnimationStyle;
    this.background_animation_style = data.backgroundAnimationStyle;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/PlayerAnnotationsExpanded.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Thumbnail from './misc/Thumbnail.js';
import NavigationEndpoint from './NavigationEndpoint.js';

export default class PlayerAnnotationsExpanded extends YTNode {
  static type = 'PlayerAnnotationsExpanded';

  featured_channel?: {
    start_time_ms: number;
    end_time_ms: number;
    watermark: Thumbnail[];
    channel_name: string;
    endpoint: NavigationEndpoint;
    subscribe_button: YTNode | null;
  };

  allow_swipe_dismiss: boolean;
  annotation_id: string;

  constructor(data: RawNode) {
    super();

    if (Reflect.has(data, 'featuredChannel')) {
      this.featured_channel = {
        start_time_ms: data.featuredChannel.startTimeMs,
        end_time_ms: data.featuredChannel.endTimeMs,
        watermark: Thumbnail.fromResponse(data.featuredChannel.watermark),
        channel_name: data.featuredChannel.channelName,
        endpoint: new NavigationEndpoint(data.featuredChannel.navigationEndpoint),
        subscribe_button: Parser.parseItem(data.featuredChannel.subscribeButton)
      };
    }

    this.allow_swipe_dismiss = data.allowSwipeDismiss;
    this.annotation_id = data.annotationId;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/PlayerCaptionsTracklist.ts:

import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export interface CaptionTrackData {
  base_url: string;
  name: Text;
  vss_id: string;
  language_code: string;
  kind?: 'asr' | 'frc';
  is_translatable: boolean;
}

export default class PlayerCaptionsTracklist extends YTNode {
  static type = 'PlayerCaptionsTracklist';

  caption_tracks?: CaptionTrackData[];

  audio_tracks?: {
    audio_track_id: string;
    captions_initial_state: string;
    default_caption_track_index?: number;
    has_default_track: boolean;
    visibility: string;
    caption_track_indices: number[];
  }[];

  default_audio_track_index?: number;

  translation_languages?: {
    language_code: string;
    language_name: Text;
  }[];

  constructor(data: RawNode) {
    super();
    if (Reflect.has(data, 'captionTracks')) {
      this.caption_tracks = data.captionTracks.map((ct: any) => ({
        base_url: ct.baseUrl,
        name: new Text(ct.name),
        vss_id: ct.vssId,
        language_code: ct.languageCode,
        kind: ct.kind,
        is_translatable: ct.isTranslatable
      }));
    }

    if (Reflect.has(data, 'audioTracks')) {
      this.audio_tracks = data.audioTracks.map((at: any) => ({
        audio_track_id: at.audioTrackId,
        captions_initial_state: at.captionsInitialState,
        default_caption_track_index: at.defaultCaptionTrackIndex,
        has_default_track: at.hasDefaultTrack,
        visibility: at.visibility,
        caption_track_indices: at.captionTrackIndices
      }));
    }

    if (Reflect.has(data, 'defaultAudioTrackIndex')) {
      this.default_audio_track_index = data.defaultAudioTrackIndex;
    }

    if (Reflect.has(data, 'translationLanguages')) {
      this.translation_languages = data.translationLanguages.map((tl: any) => ({
        language_code: tl.languageCode,
        language_name: new Text(tl.languageName)
      }));
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/PlayerControlsOverlay.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import PlayerOverflow from './PlayerOverflow.js';

export default class PlayerControlsOverlay extends YTNode {
  static type = 'PlayerControlsOverlay';

  overflow: PlayerOverflow | null;

  constructor(data: RawNode) {
    super();
    this.overflow = Parser.parseItem(data.overflow, PlayerOverflow);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/PlayerErrorMessage.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Button from './Button.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';

export default class PlayerErrorMessage extends YTNode {
  static type = 'PlayerErrorMessage';

  subreason: Text;
  reason: Text;
  proceed_button: Button | null;
  thumbnails: Thumbnail[];
  icon_type?: string;

  constructor(data: RawNode) {
    super();
    this.subreason = new Text(data.subreason);
    this.reason = new Text(data.reason);
    this.proceed_button = Parser.parseItem(data.proceedButton, Button);
    this.thumbnails = Thumbnail.fromResponse(data.thumbnail);

    if (Reflect.has(data, 'icon')) {
      this.icon_type = data.icon.iconType;
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/PlayerLegacyDesktopYpcOffer.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class PlayerLegacyDesktopYpcOffer extends YTNode {
  static type = 'PlayerLegacyDesktopYpcOffer';

  title: string;
  thumbnail: string;
  offer_description: string;
  offer_id: string;

  constructor(data: RawNode) {
    super();
    this.title = data.itemTitle;
    this.thumbnail = data.itemThumbnail;
    this.offer_description = data.offerDescription;
    this.offer_id = data.offerId;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/PlayerLegacyDesktopYpcTrailer.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import YpcTrailer from './YpcTrailer.js';

export default class PlayerLegacyDesktopYpcTrailer extends YTNode {
  static type = 'PlayerLegacyDesktopYpcTrailer';

  video_id: string;
  title: string;
  thumbnail: string;
  offer_headline: string;
  offer_description: string;
  offer_id: string;
  offer_button_text: string;
  video_message: string;
  trailer: YpcTrailer | null;

  constructor(data: RawNode) {
    super();
    this.video_id = data.trailerVideoId;
    this.title = data.itemTitle;
    this.thumbnail = data.itemThumbnail;
    this.offer_headline = data.offerHeadline;
    this.offer_description = data.offerDescription;
    this.offer_id = data.offerId;
    this.offer_button_text = data.offerButtonText;
    this.video_message = data.fullVideoMessage;
    this.trailer = Parser.parseItem(data.ypcTrailer, YpcTrailer);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/PlayerLiveStoryboardSpec.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export interface LiveStoryboardData {
  type: 'live',
  template_url: string,
  thumbnail_width: number,
  thumbnail_height: number,
  columns: number,
  rows: number
}

export default class PlayerLiveStoryboardSpec extends YTNode {
  static type = 'PlayerLiveStoryboardSpec';

  board: LiveStoryboardData;

  constructor(data: RawNode) {
    super();

    const [ template_url, thumbnail_width, thumbnail_height, columns, rows ] = data.spec.split('#');

    this.board = {
      type: 'live',
      template_url,
      thumbnail_width: parseInt(thumbnail_width, 10),
      thumbnail_height: parseInt(thumbnail_height, 10),
      columns: parseInt(columns, 10),
      rows: parseInt(rows, 10)
    };
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/PlayerMicroformat.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';

export default class PlayerMicroformat extends YTNode {
  static type = 'PlayerMicroformat';

  title: Text;
  description: Text;
  thumbnails;

  embed?: {
    iframe_url: string;
    flash_url: string;
    flash_secure_url: string;
    // TODO: check these
    width: any;
    height: any;
  };

  length_seconds: number;

  channel: {
    id: string;
    name: string;
    url: string;
  };

  is_family_safe: boolean;
  is_unlisted: boolean;
  has_ypc_metadata: boolean;
  view_count: number;
  category: string;
  publish_date: string;
  upload_date: string;
  available_countries: string[];
  start_timestamp: Date | null;
  end_timestamp: Date | null;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.description = new Text(data.description);
    this.thumbnails = Thumbnail.fromResponse(data.thumbnail);

    if (Reflect.has(data, 'embed')) {
      this.embed = {
        iframe_url: data.embed.iframeUrl,
        flash_url: data.embed.flashUrl,
        flash_secure_url: data.embed.flashSecureUrl,
        width: data.embed.width,
        height: data.embed.height
      };
    }

    this.length_seconds = parseInt(data.lengthSeconds);

    this.channel = {
      id: data.externalChannelId,
      name: data.ownerChannelName,
      url: data.ownerProfileUrl
    };

    this.is_family_safe = !!data.isFamilySafe;
    this.is_unlisted = !!data.isUnlisted;
    this.has_ypc_metadata = !!data.hasYpcMetadata;
    this.view_count = parseInt(data.viewCount);
    this.category = data.category;
    this.publish_date = data.publishDate;
    this.upload_date = data.uploadDate;
    this.available_countries = data.availableCountries;
    this.start_timestamp = data.liveBroadcastDetails?.startTimestamp ? new Date(data.liveBroadcastDetails.startTimestamp) : null;
    this.end_timestamp = data.liveBroadcastDetails?.endTimestamp ? new Date(data.liveBroadcastDetails.endTimestamp) : null;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/PlayerOverflow.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';

export default class PlayerOverflow extends YTNode {
  static type = 'PlayerOverflow';

  endpoint: NavigationEndpoint;
  enable_listen_first: boolean;

  constructor(data: RawNode) {
    super();
    this.endpoint = new NavigationEndpoint(data.endpoint);
    this.enable_listen_first = data.enableListenFirst;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/PlayerOverlay.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Button from './Button.js';
import DecoratedPlayerBar from './DecoratedPlayerBar.js';
import PlayerOverlayAutoplay from './PlayerOverlayAutoplay.js';
import WatchNextEndScreen from './WatchNextEndScreen.js';
import Menu from './menus/Menu.js';

export default class PlayerOverlay extends YTNode {
  static type = 'PlayerOverlay';

  end_screen: WatchNextEndScreen | null;
  autoplay: PlayerOverlayAutoplay | null;
  share_button: Button | null;
  add_to_menu: Menu | null;
  fullscreen_engagement: YTNode | null;
  actions: ObservedArray<YTNode>;
  browser_media_session: YTNode | null;
  decorated_player_bar: DecoratedPlayerBar | null;

  constructor(data: RawNode) {
    super();
    this.end_screen = Parser.parseItem(data.endScreen, WatchNextEndScreen);
    this.autoplay = Parser.parseItem(data.autoplay, PlayerOverlayAutoplay);
    this.share_button = Parser.parseItem(data.shareButton, Button);
    this.add_to_menu = Parser.parseItem(data.addToMenu, Menu);
    this.fullscreen_engagement = Parser.parseItem(data.fullscreenEngagement);
    this.actions = Parser.parseArray(data.actions);
    this.browser_media_session = Parser.parseItem(data.browserMediaSession);
    this.decorated_player_bar = Parser.parseItem(data.decoratedPlayerBarRenderer, DecoratedPlayerBar);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/PlayerOverlayAutoplay.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Button from './Button.js';
import Author from './misc/Author.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';

export default class PlayerOverlayAutoplay extends YTNode {
  static type = 'PlayerOverlayAutoplay';

  title: Text;
  video_id: string;
  video_title: Text;
  short_view_count: Text;

  // @TODO: Find out what these are.
  prefer_immediate_redirect;
  count_down_secs_for_fullscreen;

  published: Text;
  background: Thumbnail[];
  thumbnail_overlays: ObservedArray<YTNode>;
  author: Author;
  cancel_button: Button | null;
  next_button: Button | null;
  close_button: Button | null;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.video_id = data.videoId;
    this.video_title = new Text(data.videoTitle);
    this.short_view_count = new Text(data.shortViewCountText);
    this.prefer_immediate_redirect = data.preferImmediateRedirect;
    this.count_down_secs_for_fullscreen = data.countDownSecsForFullscreen;
    this.published = new Text(data.publishedTimeText);
    this.background = Thumbnail.fromResponse(data.background);
    this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);
    this.author = new Author(data.byline);
    this.cancel_button = Parser.parseItem(data.cancelButton, Button);
    this.next_button = Parser.parseItem(data.nextButton, Button);
    this.close_button = Parser.parseItem(data.closeButton, Button);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/PlayerStoryboardSpec.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export interface StoryboardData {
  type: 'vod'
  template_url: string;
  thumbnail_width: number;
  thumbnail_height: number;
  thumbnail_count: number;
  interval: number;
  columns: number;
  rows: number;
  storyboard_count: number;
}

export default class PlayerStoryboardSpec extends YTNode {
  static type = 'PlayerStoryboardSpec';

  boards: StoryboardData[];

  constructor(data: RawNode) {
    super();

    const parts = data.spec.split('|');
    const url = new URL(parts.shift());

    this.boards = parts.map((part: any, i: any) => {
      const [ thumbnail_width, thumbnail_height, thumbnail_count, columns, rows, interval, name, sigh ] = part.split('#');

      url.searchParams.set('sigh', sigh);

      const storyboard_count = Math.ceil(parseInt(thumbnail_count, 10) / (parseInt(columns, 10) * parseInt(rows, 10)));

      return {
        type: 'vod',
        template_url: url.toString().replace('$L', i).replace('$N', name),
        thumbnail_width: parseInt(thumbnail_width, 10),
        thumbnail_height: parseInt(thumbnail_height, 10),
        thumbnail_count: parseInt(thumbnail_count, 10),
        interval: parseInt(interval, 10),
        columns: parseInt(columns, 10),
        rows: parseInt(rows, 10),
        storyboard_count
      };
    });
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/Playlist.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import PlaylistCustomThumbnail from './PlaylistCustomThumbnail.js';
import PlaylistVideoThumbnail from './PlaylistVideoThumbnail.js';
import Author from './misc/Author.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';

export default class Playlist extends YTNode {
  static type = 'Playlist';

  id: string;
  title: Text;
  author: Text | Author;
  thumbnails: Thumbnail[];
  thumbnail_renderer?: PlaylistVideoThumbnail | PlaylistCustomThumbnail;
  video_count: Text;
  video_count_short: Text;
  first_videos: ObservedArray<YTNode>;
  share_url: string | null;
  menu: YTNode;
  badges: ObservedArray<YTNode>;
  endpoint: NavigationEndpoint;
  thumbnail_overlays;
  view_playlist?: Text;

  constructor(data: RawNode) {
    super();
    this.id = data.playlistId;
    this.title = new Text(data.title);

    this.author = data.shortBylineText?.simpleText ?
      new Text(data.shortBylineText) :
      new Author(data.longBylineText, data.ownerBadges, null);

    this.thumbnails = Thumbnail.fromResponse(data.thumbnail || { thumbnails: data.thumbnails.map((th: any) => th.thumbnails).flat(1) });
    this.video_count = new Text(data.thumbnailText);
    this.video_count_short = new Text(data.videoCountShortText);
    this.first_videos = Parser.parseArray(data.videos);
    this.share_url = data.shareUrl || null;
    this.menu = Parser.parseItem(data.menu);
    this.badges = Parser.parseArray(data.ownerBadges);
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);

    if (Reflect.has(data, 'thumbnailRenderer')) {
      this.thumbnail_renderer = Parser.parseItem(data.thumbnailRenderer, [ PlaylistVideoThumbnail, PlaylistCustomThumbnail ]) || undefined;
    }

    if (Reflect.has(data, 'viewPlaylistText')) {
      this.view_playlist = new Text(data.viewPlaylistText);
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/PlaylistCustomThumbnail.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Thumbnail from './misc/Thumbnail.js';

export default class PlaylistCustomThumbnail extends YTNode {
  static type = 'PlaylistCustomThumbnail';

  thumbnail: Thumbnail[];

  constructor(data: RawNode) {
    super();
    this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/PlaylistHeader.ts:

import Text from './misc/Text.js';
import Author from './misc/Author.js';
import { Parser, type RawNode } from '../index.js';
import { YTNode } from '../helpers.js';

export default class PlaylistHeader extends YTNode {
  static type = 'PlaylistHeader';

  id: string;
  title: Text;
  subtitle: Text | null;
  stats: Text[];
  brief_stats: Text[];
  author: Author | null;
  description: Text;
  num_videos: Text;
  view_count: Text;
  can_share: boolean;
  can_delete: boolean;
  is_editable: boolean;
  privacy: string;
  save_button: YTNode;
  shuffle_play_button: YTNode;
  menu: YTNode;
  banner: YTNode;

  constructor(data: RawNode) {
    super();
    this.id = data.playlistId;
    this.title = new Text(data.title);
    this.subtitle = data.subtitle ? new Text(data.subtitle) : null;
    this.stats = data.stats.map((stat: RawNode) => new Text(stat));
    this.brief_stats = data.briefStats.map((stat: RawNode) => new Text(stat));
    this.author = data.ownerText || data.ownerEndpoint ? new Author({ ...data.ownerText, navigationEndpoint: data.ownerEndpoint }, data.ownerBadges, null) : null;
    this.description = new Text(data.descriptionText);
    this.num_videos = new Text(data.numVideosText);
    this.view_count = new Text(data.viewCountText);
    this.can_share = data.shareData.canShare;
    this.can_delete = data.editableDetails.canDelete;
    this.is_editable = data.isEditable;
    this.privacy = data.privacy;
    this.save_button = Parser.parseItem(data.saveButton);
    this.shuffle_play_button = Parser.parseItem(data.shufflePlayButton);
    this.menu = Parser.parseItem(data.moreActionsMenu);
    this.banner = Parser.parseItem(data.playlistHeaderBanner);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/PlaylistInfoCardContent.ts:

import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class PlaylistInfoCardContent extends YTNode {
  static type = 'PlaylistInfoCardContent';

  title: Text;
  thumbnails: Thumbnail[];
  video_count: Text;
  channel_name: Text;
  endpoint: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.playlistTitle);
    this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
    this.video_count = new Text(data.playlistVideoCount);
    this.channel_name = new Text(data.channelName);
    this.endpoint = new NavigationEndpoint(data.action);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/PlaylistMetadata.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class PlaylistMetadata extends YTNode {
  static type = 'PlaylistMetadata';

  title: string;
  description: string;

  constructor(data: RawNode) {
    super();
    this.title = data.title;
    this.description = data.description || null;
    // XXX: Appindexing should be in microformat.
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/PlaylistPanel.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import AutomixPreviewVideo from './AutomixPreviewVideo.js';
import PlaylistPanelVideo from './PlaylistPanelVideo.js';
import PlaylistPanelVideoWrapper from './PlaylistPanelVideoWrapper.js';
import Text from './misc/Text.js';

export default class PlaylistPanel extends YTNode {
  static type = 'PlaylistPanel';

  title: string;
  title_text: Text;
  contents: ObservedArray<PlaylistPanelVideoWrapper | PlaylistPanelVideo | AutomixPreviewVideo>;
  playlist_id: string;
  is_infinite: boolean;
  continuation: string;
  is_editable: boolean;
  preview_description: string;
  num_items_to_show: string;

  constructor(data: RawNode) {
    super();
    this.title = data.title;
    this.title_text = new Text(data.titleText);
    this.contents = Parser.parseArray(data.contents, [ PlaylistPanelVideoWrapper, PlaylistPanelVideo, AutomixPreviewVideo ]);
    this.playlist_id = data.playlistId;
    this.is_infinite = data.isInfinite;
    this.continuation = data.continuations?.[0]?.nextRadioContinuationData?.continuation || data.continuations?.[0]?.nextContinuationData?.continuation;
    this.is_editable = data.isEditable;
    this.preview_description = data.previewDescription;
    this.num_items_to_show = data.numItemsToShow;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/PlaylistPanelVideo.ts:

import { timeToSeconds } from '../../utils/Utils.js';
import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';
import type TextRun from './misc/TextRun.js';
import Thumbnail from './misc/Thumbnail.js';

export default class PlaylistPanelVideo extends YTNode {
  static type = 'PlaylistPanelVideo';

  title: Text;
  thumbnail: Thumbnail[];
  endpoint: NavigationEndpoint;
  selected: boolean;
  video_id: string;

  duration: {
    text: string;
    seconds: number
  };

  author: string;

  album?: {
    id?: string;
    name: string;
    year?: string;
    endpoint?: NavigationEndpoint;
  };

  artists?: {
    name: string;
    channel_id?: string;
    endpoint?: NavigationEndpoint;
  }[];

  badges: ObservedArray<YTNode>;
  menu: YTNode;
  set_video_id?: string;

  constructor(data: RawNode) {
    super();

    this.title = new Text(data.title);
    this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    this.selected = data.selected;
    this.video_id = data.videoId;

    this.duration = {
      text: new Text(data.lengthText).toString(),
      seconds: timeToSeconds(new Text(data.lengthText).toString())
    };

    const album = new Text(data.longBylineText).runs?.find((run: any) => run.endpoint?.payload?.browseId?.startsWith('MPR'));
    const artists = new Text(data.longBylineText).runs?.filter((run: any) => run.endpoint?.payload?.browseId?.startsWith('UC'));

    this.author = new Text(data.shortBylineText).toString();

    if (album) {
      this.album = {
        id: (album as TextRun).endpoint?.payload?.browseId,
        name: (album as TextRun).text,
        year: new Text(data.longBylineText).runs?.slice(-1)[0].text,
        endpoint: (album as TextRun).endpoint
      };
    }

    if (artists) {
      this.artists = artists.map((artist) => ({
        name: (artist as TextRun).text,
        channel_id: (artist as TextRun).endpoint?.payload?.browseId,
        endpoint: (artist as TextRun).endpoint
      }));
    }

    this.badges = Parser.parseArray(data.badges);
    this.menu = Parser.parseItem(data.menu);
    this.set_video_id = data.playlistSetVideoId;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/PlaylistPanelVideoWrapper.ts:

import { Parser, type RawNode } from '../index.js';
import { type ObservedArray, YTNode, observe } from '../helpers.js';
import PlaylistPanelVideo from './PlaylistPanelVideo.js';

export default class PlaylistPanelVideoWrapper extends YTNode {
  static type = 'PlaylistPanelVideoWrapper';

  primary: PlaylistPanelVideo | null;
  counterpart?: ObservedArray<PlaylistPanelVideo>;

  constructor(data: RawNode) {
    super();
    this.primary = Parser.parseItem(data.primaryRenderer, PlaylistPanelVideo);

    if (Reflect.has(data, 'counterpart')) {
      this.counterpart = observe(data.counterpart.map((item: RawNode) => Parser.parseItem(item.counterpartRenderer, PlaylistPanelVideo)) || []);
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/PlaylistSidebar.ts:

import { Parser, type RawNode } from '../index.js';
import { type ObservedArray, YTNode } from '../helpers.js';

export default class PlaylistSidebar extends YTNode {
  static type = 'PlaylistSidebar';

  items: ObservedArray<YTNode>;

  constructor(data: RawNode) {
    super();
    this.items = Parser.parseArray(data.items);
  }

  // XXX: alias for consistency
  get contents() {
    return this.items;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/PlaylistSidebarPrimaryInfo.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';

export default class PlaylistSidebarPrimaryInfo extends YTNode {
  static type = 'PlaylistSidebarPrimaryInfo';

  stats: Text[];
  thumbnail_renderer: YTNode;
  title: Text;
  menu: YTNode;
  endpoint: NavigationEndpoint;
  description: Text;

  constructor(data: RawNode) {
    super();
    this.stats = data.stats.map((stat: RawNode) => new Text(stat));
    this.thumbnail_renderer = Parser.parseItem(data.thumbnailRenderer);
    this.title = new Text(data.title);
    this.menu = Parser.parseItem(data.menu);
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    this.description = new Text(data.description);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/PlaylistSidebarSecondaryInfo.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';

export default class PlaylistSidebarSecondaryInfo extends YTNode {
  static type = 'PlaylistSidebarSecondaryInfo';

  owner: YTNode;
  button: YTNode;

  constructor(data: RawNode) {
    super();
    this.owner = Parser.parseItem(data.videoOwner);
    this.button = Parser.parseItem(data.button);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/PlaylistVideo.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import ThumbnailOverlayTimeStatus from './ThumbnailOverlayTimeStatus.js';
import Menu from './menus/Menu.js';
import Author from './misc/Author.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';

export default class PlaylistVideo extends YTNode {
  static type = 'PlaylistVideo';

  id: string;
  index: Text;
  title: Text;
  author: Author;
  thumbnails: Thumbnail[];
  thumbnail_overlays: ObservedArray<YTNode>;
  set_video_id: string | undefined;
  endpoint: NavigationEndpoint;
  is_playable: boolean;
  menu: Menu | null;
  upcoming?: Date;
  video_info: Text;
  accessibility_label?: string;
  style?: string;

  duration: {
    text: string;
    seconds: number;
  };

  constructor(data: RawNode) {
    super();
    this.id = data.videoId;
    this.index = new Text(data.index);
    this.title = new Text(data.title);
    this.author = new Author(data.shortBylineText);
    this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
    this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);
    this.set_video_id = data?.setVideoId;
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    this.is_playable = data.isPlayable;
    this.menu = Parser.parseItem(data.menu, Menu);
    this.video_info = new Text(data.videoInfo);
    this.accessibility_label = data.title.accessibility.accessibilityData.label;

    if (Reflect.has(data, 'style')) {
      this.style = data.style;
    }

    const upcoming = data.upcomingEventData && Number(`${data.upcomingEventData.startTime}000`);
    if (upcoming) {
      this.upcoming = new Date(upcoming);
    }

    this.duration = {
      text: new Text(data.lengthText).toString(),
      seconds: parseInt(data.lengthSeconds)
    };
  }

  get is_live(): boolean {
    return this.thumbnail_overlays.firstOfType(ThumbnailOverlayTimeStatus)?.style === 'LIVE';
  }

  get is_upcoming(): boolean {
    return this.thumbnail_overlays.firstOfType(ThumbnailOverlayTimeStatus)?.style === 'UPCOMING';
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/PlaylistVideoList.ts:

import { Parser, type RawNode } from '../index.js';
import { type ObservedArray, YTNode } from '../helpers.js';

export default class PlaylistVideoList extends YTNode {
  static type = 'PlaylistVideoList';

  id: string;
  is_editable: boolean;
  can_reorder: boolean;
  videos: ObservedArray<YTNode>;

  constructor(data: RawNode) {
    super();
    this.id = data.playlistId;
    this.is_editable = data.isEditable;
    this.can_reorder = data.canReorder;
    this.videos = Parser.parseArray(data.contents);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/PlaylistVideoThumbnail.ts:

import Thumbnail from './misc/Thumbnail.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class PlaylistVideoThumbnail extends YTNode {
  static type = 'PlaylistVideoThumbnail';

  thumbnail: Thumbnail[];

  constructor(data: RawNode) {
    super();
    this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/Poll.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';

export default class Poll extends YTNode {
  static type = 'Poll';

  choices: {
    text: Text;
    select_endpoint: NavigationEndpoint | null;
    deselect_endpoint: NavigationEndpoint | null;
    vote_ratio_if_selected: number | null;
    vote_percentage_if_selected: Text;
    vote_ratio_if_not_selected: number | null;
    vote_percentage_if_not_selected: Text;
    image: Thumbnail[] | null;
  }[];

  poll_type?: string;
  total_votes?: Text;
  live_chat_poll_id?: string;

  constructor(data: RawNode) {
    super();

    this.choices = data.choices.map((choice: RawNode) => ({
      text: new Text(choice.text),
      select_endpoint: choice.selectServiceEndpoint ? new NavigationEndpoint(choice.selectServiceEndpoint) : null,
      deselect_endpoint: choice.deselectServiceEndpoint ? new NavigationEndpoint(choice.deselectServiceEndpoint) : null,
      vote_ratio_if_selected: choice?.voteRatioIfSelected || null,
      vote_percentage_if_selected: new Text(choice.votePercentageIfSelected),
      vote_ratio_if_not_selected: choice?.voteRatioIfSelected || null,
      vote_percentage_if_not_selected: new Text(choice.votePercentageIfSelected),
      image: choice.image ? Thumbnail.fromResponse(choice.image) : null
    }));

    if (Reflect.has(data, 'type'))
      this.poll_type = data.type;

    if (Reflect.has(data, 'totalVotes'))
      this.total_votes = new Text(data.totalVotes);

    if (Reflect.has(data, 'liveChatPollId'))
      this.live_chat_poll_id = data.liveChatPollId;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/Post.ts:

import type { RawNode } from '../index.js';
import BackstagePost from './BackstagePost.js';

export default class Post extends BackstagePost {
  static type = 'Post';

  constructor(data: RawNode) {
    super(data);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/PostMultiImage.ts:

import { Parser, type RawNode } from '../index.js';
import BackstageImage from './BackstageImage.js';
import { YTNode } from '../helpers.js';

export default class PostMultiImage extends YTNode {
  static type = 'PostMultiImage';

  images : BackstageImage[];

  constructor(data: RawNode) {
    super();
    this.images = Parser.parseArray(data.images, BackstageImage);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ProductList.ts:

import type { ObservedArray } from '../helpers.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import { Parser } from '../index.js';

export default class ProductList extends YTNode {
  static type = 'ProductList';

  contents: ObservedArray<YTNode>;

  constructor(data: RawNode) {
    super();
    this.contents = Parser.parseArray(data.contents);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ProductListHeader.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import { Text } from '../misc.js';

export default class ProductListHeader extends YTNode {
  static type = 'ProductListHeader';

  title: Text;
  suppress_padding_disclaimer: boolean;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.suppress_padding_disclaimer = !!data.suppressPaddingDisclaimer;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ProductListItem.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import { Parser } from '../index.js';
import { Text, Thumbnail } from '../misc.js';
import Button from './Button.js';
import NavigationEndpoint from './NavigationEndpoint.js';

export default class ProductListItem extends YTNode {
  static type = 'ProductListItem';

  title: Text;
  accessibility_title: string;
  thumbnail: Thumbnail[];
  price: string;
  endpoint: NavigationEndpoint;
  merchant_name: string;
  stay_in_app: boolean;
  view_button: Button | null;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.accessibility_title = data.accessibilityTitle;
    this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
    this.price = data.price;
    this.endpoint = new NavigationEndpoint(data.onClickCommand);
    this.merchant_name = data.merchantName;
    this.stay_in_app = !!data.stayInApp;
    this.view_button = Parser.parseItem(data.viewButton, Button);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ProfileColumn.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';

export default class ProfileColumn extends YTNode {
  static type = 'ProfileColumn';

  items: ObservedArray<YTNode>;

  constructor(data: RawNode) {
    super();
    this.items = Parser.parseArray(data.items);
  }

  // XXX: Alias for consistency.
  get contents() {
    return this.items;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ProfileColumnStats.ts:

import { Parser, type RawNode } from '../index.js';
import { type ObservedArray, YTNode } from '../helpers.js';

export default class ProfileColumnStats extends YTNode {
  static type = 'ProfileColumnStats';

  items: ObservedArray<YTNode>;

  constructor(data: RawNode) {
    super();
    this.items = Parser.parseArray(data.items);
  }

  // XXX: Alias for consistency.
  get contents() {
    return this.items;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ProfileColumnStatsEntry.ts:

import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class ProfileColumnStatsEntry extends YTNode {
  static type = 'ProfileColumnStatsEntry';

  label: Text;
  value: Text;

  constructor(data: RawNode) {
    super();
    this.label = new Text(data.label);
    this.value = new Text(data.value);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ProfileColumnUserInfo.ts:

import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class ProfileColumnUserInfo extends YTNode {
  static type = 'ProfileColumnUserInfo';

  title: Text;
  thumbnails: Thumbnail[];

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/Quiz.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Text from './misc/Text.js';

export default class Quiz extends YTNode {
  static type = 'Quiz';

  choices: {
    text: Text;
    is_correct: boolean;
  }[];

  total_votes: Text;

  constructor(data: RawNode) {
    super();

    this.choices = data.choices.map((choice: RawNode) => ({
      text: new Text(choice.text),
      is_correct: choice.isCorrect
    }));

    this.total_votes = new Text(data.totalVotes);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/RecognitionShelf.ts:

import { Parser, type RawNode } from '../index.js';
import { YTNode } from '../helpers.js';
import Button from './Button.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';

export default class RecognitionShelf extends YTNode {
  static type = 'RecognitionShelf';

  title: Text;
  subtitle: Text;
  avatars: Thumbnail[];
  button: Button | null;
  surface: string;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.subtitle = new Text(data.subtitle);
    this.avatars = data.avatars.map((avatar: RawNode) => new Thumbnail(avatar));
    this.button = Parser.parseItem(data.button, Button);
    this.surface = data.surface;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ReelItem.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';

export default class ReelItem extends YTNode {
  static type = 'ReelItem';

  id: string;
  title: Text;
  thumbnails: Thumbnail[];
  views: Text;
  endpoint: NavigationEndpoint;
  accessibility_label?: string;

  constructor(data: RawNode) {
    super();
    this.id = data.videoId;
    this.title = new Text(data.headline);
    this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
    this.views = new Text(data.viewCountText);
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    this.accessibility_label = data.accessibility.accessibilityData.label;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ReelPlayerHeader.ts:

import { type RawNode } from '../index.js';
import { YTNode } from '../helpers.js';
import Thumbnail from './misc/Thumbnail.js';
import Author from './misc/Author.js';
import Text from './misc/Text.js';

export default class ReelPlayerHeader extends YTNode {
  static type = 'ReelPlayerHeader';

  reel_title_text: Text;
  timestamp_text: Text;
  channel_title_text: Text;
  channel_thumbnail: Thumbnail[];
  author: Author;

  constructor(data: RawNode) {
    super();
    this.reel_title_text = new Text(data.reelTitleText);
    this.timestamp_text = new Text(data.timestampText);
    this.channel_title_text = new Text(data.channelTitleText);
    this.channel_thumbnail = Thumbnail.fromResponse(data.channelThumbnail);
    this.author = new Author(data.channelNavigationEndpoint, undefined);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ReelPlayerOverlay.ts:

import { Parser, type RawNode } from '../index.js';
import { YTNode } from '../helpers.js';
import Button from './Button.js';
import Menu from './menus/Menu.js';
import InfoPanelContainer from './InfoPanelContainer.js';
import LikeButton from './LikeButton.js';
import ReelPlayerHeader from './ReelPlayerHeader.js';
import PivotButton from './PivotButton.js';
import SubscribeButton from './SubscribeButton.js';

export default class ReelPlayerOverlay extends YTNode {
  static type = 'ReelPlayerOverlay';

  like_button: LikeButton | null;
  reel_player_header_supported_renderers: ReelPlayerHeader | null;
  menu: Menu | null;
  next_item_button: Button | null;
  prev_item_button: Button | null;
  subscribe_button_renderer: Button | null;
  style: string;
  view_comments_button: Button | null;
  share_button: Button | null;
  pivot_button: PivotButton | null;
  info_panel: InfoPanelContainer | null;

  constructor(data: RawNode) {
    super();
    this.like_button = Parser.parseItem(data.likeButton, LikeButton);
    this.reel_player_header_supported_renderers = Parser.parseItem(data.reelPlayerHeaderSupportedRenderers, ReelPlayerHeader);
    this.menu = Parser.parseItem(data.menu, Menu);
    this.next_item_button = Parser.parseItem(data.nextItemButton, Button);
    this.prev_item_button = Parser.parseItem(data.prevItemButton, Button);
    this.subscribe_button_renderer = Parser.parseItem(data.subscribeButtonRenderer, [ Button, SubscribeButton ]);
    this.style = data.style;
    this.view_comments_button = Parser.parseItem(data.viewCommentsButton, Button);
    this.share_button = Parser.parseItem(data.shareButton, Button);
    this.pivot_button = Parser.parseItem(data.pivotButton, PivotButton);
    this.info_panel = Parser.parseItem(data.infoPanel, InfoPanelContainer);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ReelShelf.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';

export default class ReelShelf extends YTNode {
  static type = 'ReelShelf';

  title: Text;
  items: ObservedArray<YTNode>;
  endpoint?: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.items = Parser.parseArray(data.items);

    if (Reflect.has(data, 'endpoint')) {
      this.endpoint = new NavigationEndpoint(data.endpoint);
    }
  }

  // XXX: Alias for consistency.
  get contents() {
    return this.items;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/RelatedChipCloud.ts:

import { Parser, type RawNode } from '../index.js';
import { YTNode } from '../helpers.js';

export default class RelatedChipCloud extends YTNode {
  static type = 'RelatedChipCloud';

  content: YTNode;

  constructor(data: RawNode) {
    super();
    this.content = Parser.parseItem(data.content);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/RichGrid.ts:

import { Parser, type RawNode } from '../index.js';
import { type ObservedArray, YTNode } from '../helpers.js';

export default class RichGrid extends YTNode {
  static type = 'RichGrid';

  header: YTNode;
  contents: ObservedArray<YTNode>;

  constructor(data: RawNode) {
    super();
    // (Daniel Wykerd) XXX: we don't parse the masthead since it is usually an advertisement
    // (Daniel Wykerd) XXX: reflowOptions aren't parsed, I think its only used internally for layout
    this.header = Parser.parseItem(data.header);
    this.contents = Parser.parseArray(data.contents);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/RichItem.ts:

import { Parser, type RawNode } from '../index.js';
import { YTNode } from '../helpers.js';

export default class RichItem extends YTNode {
  static type = 'RichItem';

  content: YTNode;

  constructor(data: RawNode) {
    super();
    this.content = Parser.parseItem(data.content);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/RichListHeader.ts:

import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class RichListHeader extends YTNode {
  static type = 'RichListHeader';

  title: Text;
  subtitle: Text;
  title_style?: string;
  icon_type?: string;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.subtitle = new Text(data.subtitle);

    if (Reflect.has(data, 'titleStyle')) {
      this.title_style = data.titleStyle.style;
    }

    if (Reflect.has(data, 'icon')) {
      this.icon_type = data.icon.iconType;
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/RichMetadata.ts:

import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class RichMetadata extends YTNode {
  static type = 'RichMetadata';

  thumbnail: Thumbnail[];
  title: Text;
  subtitle: Text;
  call_to_action: Text;
  icon_type?: string;
  endpoint: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
    this.title = new Text(data.title);
    this.subtitle = new Text(data.subtitle);
    this.call_to_action = new Text(data.callToAction);

    if (Reflect.has(data, 'callToActionIcon')) {
      this.icon_type = data.callToActionIcon.iconType;
    }

    this.endpoint = new NavigationEndpoint(data.endpoint);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/RichMetadataRow.ts:

import { Parser, type RawNode } from '../index.js';
import { type ObservedArray, YTNode } from '../helpers.js';

export default class RichMetadataRow extends YTNode {
  static type = 'RichMetadataRow';

  contents: ObservedArray<YTNode>;

  constructor(data: RawNode) {
    super();
    this.contents = Parser.parseArray(data.contents);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/RichSection.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';

export default class RichSection extends YTNode {
  static type = 'RichSection';

  content: YTNode;

  constructor(data: RawNode) {
    super();
    this.content = Parser.parseItem(data.content);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/RichShelf.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';

export default class RichShelf extends YTNode {
  static type = 'RichShelf';

  title: Text;
  contents: ObservedArray<YTNode>;
  endpoint?: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.contents = Parser.parseArray(data.contents);

    if (Reflect.has(data, 'endpoint')) {
      this.endpoint = new NavigationEndpoint(data.endpoint);
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SearchBox.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Button from './Button.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';

export default class SearchBox extends YTNode {
  static type = 'SearchBox';

  endpoint: NavigationEndpoint;
  search_button: Button | null;
  clear_button: Button | null;
  placeholder_text: Text;

  constructor(data: RawNode) {
    super();
    this.endpoint = new NavigationEndpoint(data.endpoint);
    this.search_button = Parser.parseItem(data.searchButton, Button);
    this.clear_button = Parser.parseItem(data.clearButton, Button);
    this.placeholder_text = new Text(data.placeholderText);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SearchFilter.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Text from './misc/Text.js';
import NavigationEndpoint from './NavigationEndpoint.js';

export default class SearchFilter extends YTNode {
  static type = 'SearchFilter';

  label: Text;
  endpoint: NavigationEndpoint;
  tooltip: string;
  status?: string;

  constructor(data: RawNode) {
    super();
    this.label = new Text(data.label);
    this.endpoint = new NavigationEndpoint(data.endpoint || data.navigationEndpoint);
    this.tooltip = data.tooltip;

    if (Reflect.has(data, 'status')) {
      this.status = data.status;
    }
  }

  get disabled(): boolean {
    return this.status === 'FILTER_STATUS_DISABLED';
  }

  get selected(): boolean {
    return this.status === 'FILTER_STATUS_SELECTED';
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SearchFilterGroup.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import type { RawNode } from '../index.js';
import { Parser } from '../index.js';
import Text from './misc/Text.js';
import SearchFilter from './SearchFilter.js';

export default class SearchFilterGroup extends YTNode {
  static type = 'SearchFilterGroup';

  title: Text;
  filters: ObservedArray<SearchFilter>;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.filters = Parser.parseArray(data.filters, SearchFilter);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SearchFilterOptionsDialog.ts:

import type { ObservedArray } from '../helpers.js';
import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import SearchFilterGroup from './SearchFilterGroup.js';
import Text from './misc/Text.js';

export default class SearchFilterOptionsDialog extends YTNode {
  static type = 'SearchFilterOptionsDialog';

  title: Text;
  groups: ObservedArray<SearchFilterGroup>;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.groups = Parser.parseArray(data.groups, SearchFilterGroup);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SearchHeader.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Button from './Button.js';
import ChipCloud from './ChipCloud.js';

export default class SearchHeader extends YTNode {
  static type = 'SearchHeader';

  chip_bar: ChipCloud | null;
  search_filter_button: Button | null;

  constructor(data: RawNode) {
    super();
    this.chip_bar = Parser.parseItem(data.chipBar, ChipCloud);
    this.search_filter_button = Parser.parseItem(data.searchFilterButton, Button);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SearchRefinementCard.ts:

import NavigationEndpoint from './NavigationEndpoint.js';
import Thumbnail from './misc/Thumbnail.js';
import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class SearchRefinementCard extends YTNode {
  static type = 'SearchRefinementCard';

  thumbnails: Thumbnail[];
  endpoint: NavigationEndpoint;
  query: string;

  constructor(data: RawNode) {
    super();
    this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
    this.endpoint = new NavigationEndpoint(data.searchEndpoint);
    this.query = new Text(data.query).toString();
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SearchSubMenu.ts:

import { type ObservedArray, YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import { Parser } from '../index.js';
import Text from './misc/Text.js';
import SearchFilterGroup from './SearchFilterGroup.js';
import ToggleButton from './ToggleButton.js';

export default class SearchSubMenu extends YTNode {
  static type = 'SearchSubMenu';

  title?: Text;
  groups?: ObservedArray<SearchFilterGroup>;
  button?: ToggleButton | null;

  constructor(data: RawNode) {
    super();
    if (Reflect.has(data, 'title'))
      this.title = new Text(data.title);

    if (Reflect.has(data, 'groups'))
      this.groups = Parser.parseArray(data.groups, SearchFilterGroup);

    if (Reflect.has(data, 'button'))
      this.button = Parser.parseItem(data.button, ToggleButton);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SearchSuggestion.ts:

import Text from './misc/Text.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class SearchSuggestion extends YTNode {
  static type = 'SearchSuggestion';

  suggestion: Text;
  endpoint: NavigationEndpoint;
  icon_type?: string;
  service_endpoint?: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.suggestion = new Text(data.suggestion);
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);

    if (Reflect.has(data, 'icon')) {
      this.icon_type = data.icon.iconType;
    }

    if (Reflect.has(data, 'serviceEndpoint')) {
      this.service_endpoint = new NavigationEndpoint(data.serviceEndpoint);
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SearchSuggestionsSection.ts:

import { Parser, type RawNode } from '../index.js';
import { type ObservedArray, YTNode } from '../helpers.js';

export default class SearchSuggestionsSection extends YTNode {
  static type = 'SearchSuggestionsSection';

  contents: ObservedArray<YTNode>;

  constructor(data: RawNode) {
    super();
    this.contents = Parser.parseArray(data.contents);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SecondarySearchContainer.ts:

import { Parser, type RawNode } from '../index.js';
import { type ObservedArray, YTNode } from '../helpers.js';

export default class SecondarySearchContainer extends YTNode {
  static type = 'SecondarySearchContainer';

  contents: ObservedArray<YTNode>;

  constructor(data: RawNode) {
    super();
    this.contents = Parser.parseArray(data.contents);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SectionList.ts:

import { type ObservedArray, YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';

export default class SectionList extends YTNode {
  static type = 'SectionList';

  contents: ObservedArray<YTNode>;
  target_id?: string;
  continuation?: string;
  header?: YTNode;
  sub_menu?: YTNode;

  constructor(data: RawNode) {
    super();
    this.contents = Parser.parseArray(data.contents);

    if (Reflect.has(data, 'targetId')) {
      this.target_id = data.targetId;
    }

    if (Reflect.has(data, 'continuations')) {
      if (Reflect.has(data.continuations[0], 'nextContinuationData')) {
        this.continuation = data.continuations[0].nextContinuationData.continuation;
      } else if (Reflect.has(data.continuations[0], 'reloadContinuationData')) {
        this.continuation = data.continuations[0].reloadContinuationData.continuation;
      }
    }

    if (Reflect.has(data, 'header')) {
      this.header = Parser.parseItem(data.header);
    }

    if (Reflect.has(data, 'subMenu')) {
      this.sub_menu = Parser.parseItem(data.subMenu);
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SegmentedLikeDislikeButton.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import { Parser } from '../index.js';
import Button from './Button.js';
import ToggleButton from './ToggleButton.js';

export default class SegmentedLikeDislikeButton extends YTNode {
  static type = 'SegmentedLikeDislikeButton';

  like_button: ToggleButton | Button | null;
  dislike_button: ToggleButton | Button | null;

  constructor (data: RawNode) {
    super();
    this.like_button = Parser.parseItem(data.likeButton, [ ToggleButton, Button ]);
    this.dislike_button = Parser.parseItem(data.dislikeButton, [ ToggleButton, Button ]);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SegmentedLikeDislikeButtonView.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import LikeButtonView from './LikeButtonView.js';
import DislikeButtonView from './DislikeButtonView.js';

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
    };
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SettingBoolean.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';

export default class SettingBoolean extends YTNode {
  static type = 'SettingBoolean';

  title?: Text;
  summary?: Text;
  enable_endpoint?: NavigationEndpoint;
  disable_endpoint?: NavigationEndpoint;
  item_id: string;

  constructor(data: RawNode) {
    super();
    if (Reflect.has(data, 'title')) {
      this.title = new Text(data.title);
    }

    if (Reflect.has(data, 'summary')) {
      this.summary = new Text(data.summary);
    }

    if (Reflect.has(data, 'enableServiceEndpoint')) {
      this.enable_endpoint = new NavigationEndpoint(data.enableServiceEndpoint);
    }

    if (Reflect.has(data, 'disableServiceEndpoint')) {
      this.disable_endpoint = new NavigationEndpoint(data.disableServiceEndpoint);
    }

    this.item_id = data.itemId;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SettingsCheckbox.ts:

import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class SettingsCheckbox extends YTNode {
  static type = 'SettingsCheckbox';

  title: Text;
  help_text: Text;
  enabled: boolean;
  disabled: boolean;
  id: string;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.help_text = new Text(data.helpText);
    this.enabled = data.enabled;
    this.disabled = data.disabled;
    this.id = data.id;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SettingsOptions.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import ChannelOptions from './ChannelOptions.js';
import CopyLink from './CopyLink.js';
import Dropdown from './Dropdown.js';
import SettingsCheckbox from './SettingsCheckbox.js';
import SettingsSwitch from './SettingsSwitch.js';
import Text from './misc/Text.js';

export default class SettingsOptions extends YTNode {
  static type = 'SettingsOptions';

  title: Text;
  text?: string;
  options?: ObservedArray<SettingsSwitch | Dropdown | CopyLink | SettingsCheckbox | ChannelOptions>;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);

    if (Reflect.has(data, 'text')) {
      this.text = new Text(data.text).toString();
    }

    if (Reflect.has(data, 'options')) {
      this.options = Parser.parseArray(data.options, [
        SettingsSwitch, Dropdown, CopyLink,
        SettingsCheckbox, ChannelOptions
      ]);
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SettingsSidebar.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import CompactLink from './CompactLink.js';
import Text from './misc/Text.js';

export default class SettingsSidebar extends YTNode {
  static type = 'SettingsSidebar';

  title: Text;
  items: ObservedArray<CompactLink>;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.items = Parser.parseArray(data.items, CompactLink);
  }

  // XXX: Alias for consistency.
  get contents() {
    return this.items;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SettingsSwitch.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';

export default class SettingsSwitch extends YTNode {
  static type = 'SettingsSwitch';

  title: Text;
  subtitle: Text;
  enabled: boolean;
  enable_endpoint: NavigationEndpoint;
  disable_endpoint: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.subtitle = new Text(data.subtitle);
    this.enabled = data.enabled;
    this.enable_endpoint = new NavigationEndpoint(data.enableServiceEndpoint);
    this.disable_endpoint = new NavigationEndpoint(data.disableServiceEndpoint);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SharedPost.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import * as Parser from '../parser.js';
import BackstagePost from './BackstagePost.js';
import Button from './Button.js';
import Menu from './menus/Menu.js';
import Author from './misc/Author.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import NavigationEndpoint from './NavigationEndpoint.js';

export default class SharedPost extends YTNode {
  static type = 'SharedPost';

  thumbnail: Thumbnail[];
  content: Text;
  published: Text;
  menu: Menu | null;
  original_post: BackstagePost | null;
  id: string;
  endpoint: NavigationEndpoint;
  expand_button: Button | null;
  author: Author;

  constructor(data: RawNode) {
    super();
    this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
    this.content = new Text(data.content);
    this.published = new Text(data.publishedTimeText);
    this.menu = Parser.parseItem(data.actionMenu, Menu);
    this.original_post = Parser.parseItem(data.originalPost, BackstagePost);
    this.id = data.postId;
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    this.expand_button = Parser.parseItem(data.expandButton, Button);
    this.author = new Author(data.displayName, undefined);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/Shelf.ts:

import Text from './misc/Text.js';
import { Parser, type RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import { YTNode } from '../helpers.js';
import Button from './Button.js';

export default class Shelf extends YTNode {
  static type = 'Shelf';

  title: Text;
  endpoint?: NavigationEndpoint;
  content: YTNode | null;
  icon_type?: string;
  menu?: YTNode | null;
  play_all_button?: Button | null;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);

    if (Reflect.has(data, 'endpoint')) {
      this.endpoint = new NavigationEndpoint(data.endpoint);
    }

    this.content = Parser.parseItem(data.content);

    if (Reflect.has(data, 'icon')) {
      this.icon_type = data.icon.iconType;
    }

    if (Reflect.has(data, 'menu')) {
      this.menu = Parser.parseItem(data.menu);
    }

    if (Reflect.has(data, 'playAllButton')) {
      this.play_all_button = Parser.parseItem(data.playAllButton, Button);
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ShowCustomThumbnail.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Thumbnail from './misc/Thumbnail.js';

export default class ShowCustomThumbnail extends YTNode {
  static type = 'ShowCustomThumbnail';

  thumbnail: Thumbnail[];

  constructor(data: RawNode) {
    super();
    this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ShowingResultsFor.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Text from './misc/Text.js';
import NavigationEndpoint from './NavigationEndpoint.js';

export default class ShowingResultsFor extends YTNode {
  static type = 'ShowingResultsFor';

  corrected_query: Text;
  original_query: Text;
  corrected_query_endpoint: NavigationEndpoint;
  original_query_endpoint: NavigationEndpoint;
  search_instead_for: Text;
  showing_results_for: Text;

  constructor(data: RawNode) {
    super();
    this.corrected_query = new Text(data.correctedQuery);
    this.original_query = new Text(data.originalQuery);
    this.corrected_query_endpoint = new NavigationEndpoint(data.correctedQueryEndpoint);
    this.original_query_endpoint = new NavigationEndpoint(data.originalQueryEndpoint);
    this.search_instead_for = new Text(data.searchInsteadFor);
    this.showing_results_for = new Text(data.showingResultsFor);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SimpleCardContent.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';

export default class SimpleCardContent extends YTNode {
  static type = 'SimpleCardContent';

  image: Thumbnail[];
  title: Text;
  display_domain: Text;
  show_link_icon: boolean;
  call_to_action: Text;
  endpoint: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.image = Thumbnail.fromResponse(data.image);
    this.title = new Text(data.title);
    this.display_domain = new Text(data.displayDomain);
    this.show_link_icon = data.showLinkIcon;
    this.call_to_action = new Text(data.callToAction);
    this.endpoint = new NavigationEndpoint(data.command);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SimpleCardTeaser.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Text from './misc/Text.js';

export default class SimpleCardTeaser extends YTNode {
  static type = 'SimpleCardTeaser';

  message: Text;
  prominent: boolean; // @TODO: or string?

  constructor(data: RawNode) {
    super();
    this.message = new Text(data.message);
    this.prominent = data.prominent;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SimpleTextSection.ts:

import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class SimpleTextSection extends YTNode {
  static type = 'SimpleTextSection';

  lines: Text[];
  style: string;

  constructor(data: RawNode) {
    super();
    this.lines = data.lines.map((line: RawNode) => new Text(line));
    this.style = data.layoutStyle;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SingleActionEmergencySupport.ts:

import Text from './misc/Text.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class SingleActionEmergencySupport extends YTNode {
  static type = 'SingleActionEmergencySupport';

  action_text: Text;
  nav_text: Text;
  details: Text;
  icon_type: string;
  endpoint: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.action_text = new Text(data.actionText);
    this.nav_text = new Text(data.navigationText);
    this.details = new Text(data.detailsText);
    this.icon_type = data.icon.iconType;
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SingleColumnBrowseResults.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Tab from './Tab.js';

export default class SingleColumnBrowseResults extends YTNode {
  static type = 'SingleColumnBrowseResults';

  tabs: ObservedArray<Tab>;

  constructor(data: RawNode) {
    super();
    this.tabs = Parser.parseArray(data.tabs, Tab);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SingleColumnMusicWatchNextResults.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';

export default class SingleColumnMusicWatchNextResults extends YTNode {
  static type = 'SingleColumnMusicWatchNextResults';

  contents;

  constructor(data: RawNode) {
    super();
    this.contents = Parser.parse(data);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SingleHeroImage.ts:

import Thumbnail from './misc/Thumbnail.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class SingleHeroImage extends YTNode {
  static type = 'SingleHeroImage';

  thumbnails: Thumbnail[];
  style: string;

  constructor(data: RawNode) {
    super();
    this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
    this.style = data.style;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SlimOwner.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import SubscribeButton from './SubscribeButton.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';

export default class SlimOwner extends YTNode {
  static type = 'SlimOwner';

  thumbnail: Thumbnail[];
  title: Text;
  endpoint: NavigationEndpoint;
  subscribe_button: SubscribeButton | null;

  constructor(data: RawNode) {
    super();
    this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
    this.title = new Text(data.title);
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    this.subscribe_button = Parser.parseItem(data.subscribeButton, SubscribeButton);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SlimVideoMetadata.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Text from './misc/Text.js';

export default class SlimVideoMetadata extends YTNode {
  static type = 'SlimVideoMetadata';

  title: Text;
  collapsed_subtitle: Text;
  expanded_subtitle: Text;
  owner: YTNode;
  description: Text;
  video_id: string;
  date: Text;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.collapsed_subtitle = new Text(data.collapsedSubtitle);
    this.expanded_subtitle = new Text(data.expandedSubtitle);
    this.owner = Parser.parseItem(data.owner);
    this.description = new Text(data.description);
    this.video_id = data.videoId;
    this.date = new Text(data.dateText);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SortFilterHeader.ts:

import { YTNode } from '../helpers.js';
import { Parser, YTNodes, type RawNode } from '../index.js';

export default class SortFilterHeader extends YTNode {
  static type = 'SortFilterHeader';

  filter_menu: YTNodes.SortFilterSubMenu | null;

  constructor(data: RawNode) {
    super();
    this.filter_menu = Parser.parseItem(data.filterMenu, YTNodes.SortFilterSubMenu);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SortFilterSubMenu.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';

export default class SortFilterSubMenu extends YTNode {
  static type = 'SortFilterSubMenu';

  title?: string;
  icon_type?: string;
  label?: string;
  tooltip?: string;

  sub_menu_items?: {
    title: string;
    selected: boolean;
    continuation: string;
    endpoint: NavigationEndpoint;
    subtitle: string | null;
  }[];

  constructor(data: RawNode) {
    super();
    if (Reflect.has(data, 'title')) {
      this.title = data.title;
    }

    if (Reflect.has(data, 'icon')) {
      this.icon_type = data.icon.iconType;
    }

    if (Reflect.has(data, 'accessibility')) {
      this.label = data.accessibility.accessibilityData.label;
    }

    if (Reflect.has(data, 'tooltip')) {
      this.tooltip = data.tooltip;
    }

    if (Reflect.has(data, 'subMenuItems')) {
      this.sub_menu_items = data.subMenuItems.map((item: RawNode) => ({
        title: item.title,
        selected: item.selected,
        continuation: item.continuation?.reloadContinuationData?.continuation,
        endpoint: new NavigationEndpoint(item.serviceEndpoint || item.navigationEndpoint),
        subtitle: item.subtitle || null
      }));
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/StructuredDescriptionContent.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import ExpandableVideoDescriptionBody from './ExpandableVideoDescriptionBody.js';
import HorizontalCardList from './HorizontalCardList.js';
import VideoDescriptionHeader from './VideoDescriptionHeader.js';
import VideoDescriptionInfocardsSection from './VideoDescriptionInfocardsSection.js';
import VideoDescriptionMusicSection from './VideoDescriptionMusicSection.js';
import VideoDescriptionTranscriptSection from './VideoDescriptionTranscriptSection.js';
import VideoDescriptionCourseSection from './VideoDescriptionCourseSection.js';
import ReelShelf from './ReelShelf.js';
import VideoAttributesSectionView from './VideoAttributesSectionView.js';

export default class StructuredDescriptionContent extends YTNode {
  static type = 'StructuredDescriptionContent';

  items: ObservedArray<
    VideoDescriptionHeader | ExpandableVideoDescriptionBody | VideoDescriptionMusicSection |
    VideoDescriptionInfocardsSection | VideoDescriptionTranscriptSection | VideoDescriptionTranscriptSection |
    VideoDescriptionCourseSection | HorizontalCardList | ReelShelf | VideoAttributesSectionView
  >;

  constructor(data: RawNode) {
    super();
    this.items = Parser.parseArray(data.items, [
      VideoDescriptionHeader, ExpandableVideoDescriptionBody, VideoDescriptionMusicSection,
      VideoDescriptionInfocardsSection, VideoDescriptionCourseSection, VideoDescriptionTranscriptSection,
      VideoDescriptionTranscriptSection, HorizontalCardList, ReelShelf, VideoAttributesSectionView
    ]);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/StructuredDescriptionPlaylistLockup.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';

export default class StructuredDescriptionPlaylistLockup extends YTNode {
  static type = 'StructuredDescriptionPlaylistLockup';

  thumbnail: Thumbnail[];
  title: Text;
  short_byline_text: Text;
  video_count_short_text: Text;
  endpoint: NavigationEndpoint;
  thumbnail_width: number;
  aspect_ratio: number;
  max_lines_title: number;
  max_lines_short_byline_text: number;
  overlay_position: string;

  constructor(data: RawNode) {
    super();
    this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
    this.title = new Text(data.title);
    this.short_byline_text = new Text(data.shortBylineText);
    this.video_count_short_text = new Text(data.videoCountShortText);
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    this.thumbnail_width = data.thumbnailWidth;
    this.aspect_ratio = data.aspectRatio;
    this.max_lines_title = data.maxLinesTitle;
    this.max_lines_short_byline_text = data.maxLinesShortBylineText;
    this.overlay_position = data.overlayPosition;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SubFeedOption.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';

export default class SubFeedOption extends YTNode {
  static type = 'SubFeedOption';

  name: Text;
  is_selected: boolean;
  endpoint: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.name = new Text(data.name);
    this.is_selected = data.isSelected;
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SubFeedSelector.ts:

import { Parser, type RawNode } from '../index.js';
import Text from './misc/Text.js';
import { type ObservedArray, YTNode } from '../helpers.js';
import SubFeedOption from './SubFeedOption.js';

export default class SubFeedSelector extends YTNode {
  static type = 'SubFeedSelector';

  title: Text;
  options: ObservedArray<SubFeedOption>;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.options = Parser.parseArray(data.options, SubFeedOption);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SubscribeButton.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import SubscriptionNotificationToggleButton from './SubscriptionNotificationToggleButton.js';
import Text from './misc/Text.js';

export default class SubscribeButton extends YTNode {
  static type = 'SubscribeButton';

  title: Text;
  subscribed: boolean;
  enabled: boolean;
  item_type: string;
  channel_id: string;
  show_preferences: boolean;
  subscribed_text: Text;
  unsubscribed_text: Text;
  notification_preference_button: SubscriptionNotificationToggleButton | null;
  endpoint: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.buttonText);
    this.subscribed = data.subscribed;
    this.enabled = data.enabled;
    this.item_type = data.type;
    this.channel_id = data.channelId;
    this.show_preferences = data.showPreferences;
    this.subscribed_text = new Text(data.subscribedButtonText);
    this.unsubscribed_text = new Text(data.unsubscribedButtonText);
    this.notification_preference_button = Parser.parseItem(data.notificationPreferenceButton, SubscriptionNotificationToggleButton);
    this.endpoint = new NavigationEndpoint(data.serviceEndpoints?.[0] || data.onSubscribeEndpoints?.[0]);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/SubscriptionNotificationToggleButton.ts:

import { Parser, type RawNode } from '../index.js';
import { type SuperParsedResult, YTNode } from '../helpers.js';

export default class SubscriptionNotificationToggleButton extends YTNode {
  static type = 'SubscriptionNotificationToggleButton';

  states: {
    id: string;
    next_id: string;
    state: SuperParsedResult<YTNode>;
  };

  current_state_id: string;
  target_id: string;

  constructor(data: any) {
    super();
    this.states = data.states.map((data: RawNode) => ({
      id: data.stateId,
      next_id: data.nextStateId,
      state: Parser.parse(data.state)
    }));

    this.current_state_id = data.currentStateId;
    this.target_id = data.targetId;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/Tab.ts:

import { Parser, type RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import SectionList from './SectionList.js';
import MusicQueue from './MusicQueue.js';
import RichGrid from './RichGrid.js';
import { YTNode } from '../helpers.js';

export default class Tab extends YTNode {
  static type = 'Tab';

  title: string;
  selected: boolean;
  endpoint: NavigationEndpoint;
  content: SectionList | MusicQueue | RichGrid | null;

  constructor(data: RawNode) {
    super();
    this.title = data.title || 'N/A';
    this.selected = !!data.selected;
    this.endpoint = new NavigationEndpoint(data.endpoint);
    this.content = Parser.parseItem(data.content, [ SectionList, MusicQueue, RichGrid ]);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/Tabbed.ts:

import { YTNode, type SuperParsedResult } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';

export default class Tabbed extends YTNode {
  static type = 'Tabbed';

  contents: SuperParsedResult<YTNode>;

  constructor(data: RawNode) {
    super();
    this.contents = Parser.parse(data);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/TabbedSearchResults.ts:

import { Parser, type RawNode } from '../index.js';
import { type ObservedArray, YTNode } from '../helpers.js';
import Tab from './Tab.js';

export default class TabbedSearchResults extends YTNode {
  static type = 'TabbedSearchResults';

  tabs: ObservedArray<Tab>;

  constructor(data: RawNode) {
    super();
    this.tabs = Parser.parseArray(data.tabs, Tab);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/TextHeader.ts:

import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class TextHeader extends YTNode {
  static type = 'TextHeader';

  title: Text;
  style: string;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.style = data.style;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ThumbnailBadgeView.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class ThumbnailBadgeView extends YTNode {
  static type = 'ThumbnailBadgeView';

  icon_name: string;
  text: string;
  badge_style: string;
  background_color?: {
    light_theme: number;
    dark_theme: number;
  };

  constructor(data: RawNode) {
    super();

    this.icon_name = data.icon.sources[0].clientResource.imageName;
    this.text = data.text;
    this.badge_style = data.badgeStyle;
    if (data.backgroundColor) {
      this.background_color = {
        light_theme: data.backgroundColor.lightTheme,
        dark_theme: data.backgroundColor.darkTheme
      };
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ThumbnailHoverOverlayView.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Text from './misc/Text.js';

export default class ThumbnailHoverOverlayView extends YTNode {
  static type = 'ThumbnailHoverOverlayView';

  icon_name: string;
  text: Text;
  style: string;

  constructor(data: RawNode) {
    super();

    this.icon_name = data.icon.sources[0].clientResource.imageName;
    this.text = Text.fromAttributed(data.text);
    this.style = data.style;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ThumbnailLandscapePortrait.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Thumbnail from './misc/Thumbnail.js';

export default class ThumbnailLandscapePortrait extends YTNode {
  static type = 'ThumbnailLandscapePortrait';

  landscape: Thumbnail[];
  portrait: Thumbnail[];

  constructor (data: RawNode) {
    super();
    this.landscape = Thumbnail.fromResponse(data.landscape);
    this.portrait = Thumbnail.fromResponse(data.portrait);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ThumbnailOverlayBadgeView.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import ThumbnailBadgeView from './ThumbnailBadgeView.js';

export default class ThumbnailOverlayBadgeView extends YTNode {
  static type = 'ThumbnailOverlayBadgeView';

  badges: ThumbnailBadgeView[];
  position: string;

  constructor(data: RawNode) {
    super();

    this.badges = Parser.parseArray(data.thumbnailBadges, ThumbnailBadgeView);
    this.position = data.position;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ThumbnailOverlayBottomPanel.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Text from './misc/Text.js';

export default class ThumbnailOverlayBottomPanel extends YTNode {
  static type = 'ThumbnailOverlayBottomPanel';

  text?: Text;
  icon_type?: string;

  constructor(data: RawNode) {
    super();
    if (Reflect.has(data, 'text')) {
      this.text = new Text(data.text);
    }

    if (Reflect.has(data, 'icon') && Reflect.has(data.icon, 'iconType')) {
      this.icon_type = data.icon.iconType;
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ThumbnailOverlayEndorsement.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Text from './misc/Text.js';

export default class ThumbnailOverlayEndorsement extends YTNode {
  static type = 'ThumbnailOverlayEndorsement';

  text: string;

  constructor(data: RawNode) {
    super();
    this.text = new Text(data.text).toString();
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ThumbnailOverlayHoverText.ts:

import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class ThumbnailOverlayHoverText extends YTNode {
  static type = 'ThumbnailOverlayHoverText';

  text: Text;
  icon_type: string;

  constructor(data: RawNode) {
    super();
    this.text = new Text(data.text);
    this.icon_type = data.icon.iconType;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ThumbnailOverlayInlineUnplayable.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Text from './misc/Text.js';

export default class ThumbnailOverlayInlineUnplayable extends YTNode {
  static type = 'ThumbnailOverlayInlineUnplayable';

  text: string;
  icon_type: string;

  constructor(data: RawNode) {
    super();
    this.text = new Text(data.text).toString();
    this.icon_type = data.icon.iconType;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ThumbnailOverlayLoadingPreview.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Text from './misc/Text.js';

export default class ThumbnailOverlayLoadingPreview extends YTNode {
  static type = 'ThumbnailOverlayLoadingPreview';

  text: Text;

  constructor(data: RawNode) {
    super();
    this.text = new Text(data.text);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ThumbnailOverlayNowPlaying.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Text from './misc/Text.js';

export default class ThumbnailOverlayNowPlaying extends YTNode {
  static type = 'ThumbnailOverlayNowPlaying';

  text: string;

  constructor(data: RawNode) {
    super();
    this.text = new Text(data.text).toString();
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ThumbnailOverlayPinking.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class ThumbnailOverlayPinking extends YTNode {
  static type = 'ThumbnailOverlayPinking';

  hack: boolean;

  constructor(data: RawNode) {
    super();
    this.hack = data.hack;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ThumbnailOverlayPlaybackStatus.ts:

import { YTNode } from '../helpers.js';
import { type RawNode } from '../index.js';
import Text from './misc/Text.js';

export default class ThumbnailOverlayPlaybackStatus extends YTNode {
  static type = 'ThumbnailOverlayPlaybackStatus';

  texts: Text[];

  constructor(data: RawNode) {
    super();
    this.texts = data.texts.map((text: RawNode) => new Text(text));
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ThumbnailOverlayResumePlayback.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class ThumbnailOverlayResumePlayback extends YTNode {
  static type = 'ThumbnailOverlayResumePlayback';

  percent_duration_watched: number;

  constructor(data: RawNode) {
    super();
    this.percent_duration_watched = data.percentDurationWatched;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ThumbnailOverlaySidePanel.ts:

import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class ThumbnailOverlaySidePanel extends YTNode {
  static type = 'ThumbnailOverlaySidePanel';

  text: Text;
  icon_type: string;

  constructor(data: RawNode) {
    super();
    this.text = new Text(data.text);
    this.icon_type = data.icon.iconType;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ThumbnailOverlayTimeStatus.ts:

import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class ThumbnailOverlayTimeStatus extends YTNode {
  static type = 'ThumbnailOverlayTimeStatus';

  text: string;
  style: string;

  constructor(data: RawNode) {
    super();
    this.text = new Text(data.text).toString();
    this.style = data.style;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ThumbnailOverlayToggleButton.ts:

import NavigationEndpoint from './NavigationEndpoint.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class ThumbnailOverlayToggleButton extends YTNode {
  static type = 'ThumbnailOverlayToggleButton';

  is_toggled?: boolean;

  icon_type: {
    toggled: string;
    untoggled: string;
  };

  tooltip: {
    toggled: string;
    untoggled: string;
  };

  toggled_endpoint?: NavigationEndpoint;
  untoggled_endpoint?: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    if (Reflect.has(data, 'isToggled')) {
      this.is_toggled = data.isToggled;
    }

    this.icon_type = {
      toggled: data.toggledIcon.iconType,
      untoggled: data.untoggledIcon.iconType
    };

    this.tooltip = {
      toggled: data.toggledTooltip,
      untoggled: data.untoggledTooltip
    };

    if (data.toggledServiceEndpoint)
      this.toggled_endpoint = new NavigationEndpoint(data.toggledServiceEndpoint);

    if (data.untoggledServiceEndpoint)
      this.untoggled_endpoint = new NavigationEndpoint(data.untoggledServiceEndpoint);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ThumbnailView.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import ThumbnailHoverOverlayView from './ThumbnailHoverOverlayView.js';
import ThumbnailOverlayBadgeView from './ThumbnailOverlayBadgeView.js';
import Thumbnail from './misc/Thumbnail.js';

export default class ThumbnailView extends YTNode {
  static type = 'ThumbnailView';

  image: Thumbnail[];
  overlays: (ThumbnailOverlayBadgeView | ThumbnailHoverOverlayView)[];
  background_color?: {
    light_theme: number;
    dark_theme: number;
  };

  constructor(data: RawNode) {
    super();

    this.image = Thumbnail.fromResponse(data.image);
    this.overlays = Parser.parseArray(data.overlays, [ ThumbnailOverlayBadgeView, ThumbnailHoverOverlayView ]);
    if (data.backgroundColor) {
      this.background_color = {
        light_theme: data.backgroundColor.lightTheme,
        dark_theme: data.backgroundColor.darkTheme
      };
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/TimedMarkerDecoration.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Text from './misc/Text.js';

export default class TimedMarkerDecoration extends YTNode {
  static type = 'TimedMarkerDecoration';

  visible_time_range_start_millis: number;
  visible_time_range_end_millis: number;
  decoration_time_millis: number;
  label: Text;
  icon: string;

  constructor(data: RawNode) {
    super();
    this.visible_time_range_start_millis = data.visibleTimeRangeStartMillis;
    this.visible_time_range_end_millis = data.visibleTimeRangeEndMillis;
    this.decoration_time_millis = data.decorationTimeMillis;
    this.label = new Text(data.label);
    this.icon = data.icon;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/TitleAndButtonListHeader.ts:

import Text from './misc/Text.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class TitleAndButtonListHeader extends YTNode {
  static type = 'TitleAndButtonListHeader';

  title: Text;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ToggleButton.ts:

import Text from './misc/Text.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class ToggleButton extends YTNode {
  static type = 'ToggleButton';

  text: Text;
  toggled_text: Text;
  tooltip: string;
  toggled_tooltip: string;
  is_toggled: boolean;
  is_disabled: boolean;
  icon_type: string;
  like_count?: number;
  short_like_count?: string;
  endpoint: NavigationEndpoint;
  toggled_endpoint: NavigationEndpoint;
  button_id?: string;
  target_id?: string;

  constructor(data: RawNode) {
    super();
    this.text = new Text(data.defaultText);
    this.toggled_text = new Text(data.toggledText);
    this.tooltip = data.defaultTooltip;
    this.toggled_tooltip = data.toggledTooltip;
    this.is_toggled = data.isToggled;
    this.is_disabled = data.isDisabled;
    this.icon_type = data.defaultIcon?.iconType;

    const acc_label =
      data?.defaultText?.accessibility?.accessibilityData?.label ||
      data?.accessibilityData?.accessibilityData?.label ||
      data?.accessibility?.label;

    if (this.icon_type == 'LIKE') {
      this.like_count = parseInt(acc_label.replace(/\D/g, ''));
      this.short_like_count = new Text(data.defaultText).toString();
    }

    this.endpoint =
      data.defaultServiceEndpoint?.commandExecutorCommand?.commands ?
        new NavigationEndpoint(data.defaultServiceEndpoint.commandExecutorCommand.commands.pop()) :
        new NavigationEndpoint(data.defaultServiceEndpoint);

    this.toggled_endpoint = new NavigationEndpoint(data.toggledServiceEndpoint);

    if (Reflect.has(data, 'toggleButtonSupportedData') && Reflect.has(data.toggleButtonSupportedData, 'toggleButtonIdData')) {
      this.button_id = data.toggleButtonSupportedData.toggleButtonIdData.id;
    }

    if (Reflect.has(data, 'targetId')) {
      this.target_id = data.targetId;
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ToggleButtonView.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import ButtonView from './ButtonView.js';

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;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ToggleMenuServiceItem.ts:

import Text from './misc/Text.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class ToggleMenuServiceItem extends YTNode {
  static type = 'ToggleMenuServiceItem';

  text: Text;
  toggled_text: Text;
  icon_type: string;
  toggled_icon_type: string;
  default_endpoint: NavigationEndpoint;
  toggled_endpoint: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.text = new Text(data.defaultText);
    this.toggled_text = new Text(data.toggledText);
    this.icon_type = data.defaultIcon.iconType;
    this.toggled_icon_type = data.toggledIcon.iconType;
    this.default_endpoint = new NavigationEndpoint(data.defaultServiceEndpoint);
    this.toggled_endpoint = new NavigationEndpoint(data.toggledServiceEndpoint);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/Tooltip.ts:

import Text from './misc/Text.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class Tooltip extends YTNode {
  static type = 'Tooltip';

  promo_config: {
    promo_id: string;
    impression_endpoints: NavigationEndpoint[];
    accept: NavigationEndpoint;
    dismiss: NavigationEndpoint;
  };

  target_id: string;
  details: Text;
  suggested_position: string;
  dismiss_stratedy: string;
  dwell_time_ms: number;

  constructor(data: RawNode) {
    super();
    this.promo_config = {
      promo_id: data.promoConfig.promoId,
      impression_endpoints: data.promoConfig.impressionEndpoints
        .map((endpoint: RawNode) => new NavigationEndpoint(endpoint)),
      accept: new NavigationEndpoint(data.promoConfig.acceptCommand),
      dismiss: new NavigationEndpoint(data.promoConfig.dismissCommand)
    };

    this.target_id = data.targetId;
    this.details = new Text(data.detailsText);
    this.suggested_position = data.suggestedPosition.type;
    this.dismiss_stratedy = data.dismissStrategy.type;
    this.dwell_time_ms = parseInt(data.dwellTimeMs);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/TopicChannelDetails.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import SubscribeButton from './SubscribeButton.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';

export default class TopicChannelDetails extends YTNode {
  static type = 'TopicChannelDetails';

  title: Text;
  avatar: Thumbnail[];
  subtitle: Text;
  subscribe_button: SubscribeButton | null;
  endpoint: NavigationEndpoint;

  constructor (data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.avatar = Thumbnail.fromResponse(data.thumbnail ?? data.avatar);
    this.subtitle = new Text(data.subtitle);
    this.subscribe_button = Parser.parseItem(data.subscribeButton, SubscribeButton);
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/Transcript.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import { Parser } from '../index.js';
import TranscriptSearchPanel from './TranscriptSearchPanel.js';

export default class Transcript extends YTNode {
  static type = 'Transcript';

  content: TranscriptSearchPanel | null;

  constructor(data: RawNode) {
    super();
    this.content = Parser.parseItem(data.content, TranscriptSearchPanel);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/TranscriptFooter.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import { Parser } from '../index.js';
import SortFilterSubMenu from './SortFilterSubMenu.js';

export default class TranscriptFooter extends YTNode {
  static type = 'TranscriptFooter';

  language_menu: SortFilterSubMenu | null;

  constructor(data: RawNode) {
    super();
    this.language_menu = Parser.parseItem(data.languageMenu, SortFilterSubMenu);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/TranscriptSearchBox.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import { Parser } from '../index.js';
import Button from './Button.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import { Text } from '../misc.js';

export default class TranscriptSearchBox extends YTNode {
  static type = 'TranscriptSearchBox';

  formatted_placeholder: Text;
  clear_button: Button | null;
  endpoint: NavigationEndpoint;
  search_button: Button | null;

  constructor(data: RawNode) {
    super();
    this.formatted_placeholder = new Text(data.formattedPlaceholder);
    this.clear_button = Parser.parseItem(data.clearButton, Button);
    this.endpoint = new NavigationEndpoint(data.onTextChangeCommand);
    this.search_button = Parser.parseItem(data.searchButton, Button);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/TranscriptSearchPanel.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import { Parser } from '../index.js';
import TranscriptFooter from './TranscriptFooter.js';
import TranscriptSearchBox from './TranscriptSearchBox.js';
import TranscriptSegmentList from './TranscriptSegmentList.js';

export default class TranscriptSearchPanel extends YTNode {
  static type = 'TranscriptSearchPanel';

  header: TranscriptSearchBox | null;
  body: TranscriptSegmentList | null;
  footer: TranscriptFooter | null;
  target_id: string;

  constructor(data: RawNode) {
    super();
    this.header = Parser.parseItem(data.header, TranscriptSearchBox);
    this.body = Parser.parseItem(data.body, TranscriptSegmentList);
    this.footer = Parser.parseItem(data.footer, TranscriptFooter);
    this.target_id = data.targetId;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/TranscriptSectionHeader.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Text from './misc/Text.js';

export default class TranscriptSectionHeader extends YTNode {
  static type = 'TranscriptSectionHeader';

  start_ms: string;
  end_ms: string;
  snippet: Text;

  constructor(data: RawNode) {
    super();
    this.start_ms = data.startMs;
    this.end_ms = data.endMs;
    this.snippet = new Text(data.snippet);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/TranscriptSegment.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import { Text } from '../misc.js';

export default class TranscriptSegment extends YTNode {
  static type = 'TranscriptSegment';

  start_ms: string;
  end_ms: string;
  snippet: Text;
  start_time_text: Text;
  target_id: string;

  constructor(data: RawNode) {
    super();
    this.start_ms = data.startMs;
    this.end_ms = data.endMs;
    this.snippet = new Text(data.snippet);
    this.start_time_text = new Text(data.startTimeText);
    this.target_id = data.targetId;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/TranscriptSegmentList.ts:

import type { ObservedArray } from '../helpers.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import { Parser } from '../index.js';
import { Text } from '../misc.js';
import TranscriptSectionHeader from './TranscriptSectionHeader.js';
import TranscriptSegment from './TranscriptSegment.js';

export default class TranscriptSegmentList extends YTNode {
  static type = 'TranscriptSegmentList';

  initial_segments: ObservedArray<TranscriptSegment | TranscriptSectionHeader>;
  no_result_label: Text;
  retry_label: Text;
  touch_captions_enabled: boolean;

  constructor(data: RawNode) {
    super();
    this.initial_segments = Parser.parseArray(data.initialSegments, [ TranscriptSegment, TranscriptSectionHeader ]);
    this.no_result_label = new Text(data.noResultLabel);
    this.retry_label = new Text(data.retryLabel);
    this.touch_captions_enabled = data.touchCaptionsEnabled;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/TwoColumnBrowseResults.ts:

import { YTNode, type SuperParsedResult } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';

export default class TwoColumnBrowseResults extends YTNode {
  static type = 'TwoColumnBrowseResults';

  tabs: SuperParsedResult<YTNode>;
  secondary_contents: SuperParsedResult<YTNode>;

  constructor(data: RawNode) {
    super();
    this.tabs = Parser.parse(data.tabs);
    this.secondary_contents = Parser.parse(data.secondaryContents);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/TwoColumnSearchResults.ts:

import { YTNode, type SuperParsedResult } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';

export default class TwoColumnSearchResults extends YTNode {
  static type = 'TwoColumnSearchResults';

  primary_contents: SuperParsedResult<YTNode>;
  secondary_contents: SuperParsedResult<YTNode>;

  constructor(data: RawNode) {
    super();
    this.primary_contents = Parser.parse(data.primaryContents);
    this.secondary_contents = Parser.parse(data.secondaryContents);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/TwoColumnWatchNextResults.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Menu from './menus/Menu.js';
import Author from './misc/Author.js';
import Text from './misc/Text.js';

type AutoplaySet = {
  autoplay_video: NavigationEndpoint,
  next_button_video?: NavigationEndpoint
};

export default class TwoColumnWatchNextResults extends YTNode {
  static type = 'TwoColumnWatchNextResults';

  results: ObservedArray<YTNode>;
  secondary_results: ObservedArray<YTNode>;
  conversation_bar: YTNode;
  playlist?: {
    id: string,
    title: string,
    author: Text | Author,
    contents: YTNode[],
    current_index: number,
    is_infinite: boolean,
    menu: Menu | null
  };
  autoplay?: {
    sets: AutoplaySet[],
    modified_sets?: AutoplaySet[],
    count_down_secs?: number
  };

  constructor(data: RawNode) {
    super();
    this.results = Parser.parseArray(data.results?.results.contents);
    this.secondary_results = Parser.parseArray(data.secondaryResults?.secondaryResults.results);
    this.conversation_bar = Parser.parseItem(data?.conversationBar);

    const playlistData = data.playlist?.playlist;

    if (playlistData) {
      this.playlist = {
        id: playlistData.playlistId,
        title: playlistData.title,
        author: playlistData.shortBylineText?.simpleText ?
          new Text(playlistData.shortBylineText) :
          new Author(playlistData.longBylineText),
        contents: Parser.parseArray(playlistData.contents),
        current_index: playlistData.currentIndex,
        is_infinite: !!playlistData.isInfinite,
        menu: Parser.parseItem(playlistData.menu, Menu)
      };
    }

    const autoplayData = data.autoplay?.autoplay;
    if (autoplayData) {
      this.autoplay = {
        sets: autoplayData.sets.map((set: RawNode) => this.#parseAutoplaySet(set))
      };
      if (autoplayData.modifiedSets) {
        this.autoplay.modified_sets = autoplayData.modifiedSets.map((set: any) => this.#parseAutoplaySet(set));
      }
      if (autoplayData.countDownSecs) {
        this.autoplay.count_down_secs = autoplayData.countDownSecs;
      }
    }
  }

  #parseAutoplaySet(data: RawNode): AutoplaySet {
    const result = {
      autoplay_video: new NavigationEndpoint(data.autoplayVideo)
    } as AutoplaySet;

    if (data.nextButtonVideo) {
      result.next_button_video = new NavigationEndpoint(data.nextButtonVideo);
    }

    return result;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/UniversalWatchCard.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Text from './misc/Text.js';

export default class UniversalWatchCard extends YTNode {
  static type = 'UniversalWatchCard';

  header: YTNode;
  call_to_action: YTNode;
  sections: ObservedArray<YTNode>;
  collapsed_label?: Text;

  constructor(data: RawNode) {
    super();
    this.header = Parser.parseItem(data.header);
    this.call_to_action = Parser.parseItem(data.callToAction);
    this.sections = Parser.parseArray(data.sections);
    if (Reflect.has(data, 'collapsedLabel')) {
      this.collapsed_label = new Text(data.collapsedLabel);
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/UploadTimeFactoid.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import { Parser } from '../index.js';
import Factoid from './Factoid.js';

export default class UploadTimeFactoid extends YTNode {
  static type = 'UploadTimeFactoid';

  factoid: Factoid | null;

  constructor(data: RawNode) {
    super();
    this.factoid = Parser.parseItem(data.factoid, Factoid);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/UpsellDialog.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Button from './Button.js';
import Text from './misc/Text.js';

export default class UpsellDialog extends YTNode {
  static type = 'UpsellDialog';

  message_title: Text;
  message_text: Text;
  action_button: Button | null;
  dismiss_button: Button | null;
  is_visible: boolean;

  constructor(data: RawNode) {
    super();
    this.message_title = new Text(data.dialogMessageTitle);
    this.message_text = new Text(data.dialogMessageText);
    this.action_button = Parser.parseItem(data.actionButton, Button);
    this.dismiss_button = Parser.parseItem(data.dismissButton, Button);
    this.is_visible = data.isVisible;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/VerticalList.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Text from './misc/Text.js';

export default class VerticalList extends YTNode {
  static type = 'VerticalList';

  items: ObservedArray<YTNode>;
  collapsed_item_count: string; // Number?
  collapsed_state_button_text: Text;

  constructor(data: RawNode) {
    super();
    this.items = Parser.parseArray(data.items);
    this.collapsed_item_count = data.collapsedItemCount;
    this.collapsed_state_button_text = new Text(data.collapsedStateButtonText);
  }

  // XXX: Alias for consistency.
  get contents() {
    return this.items;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/VerticalWatchCardList.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';

export default class VerticalWatchCardList extends YTNode {
  static type = 'VerticalWatchCardList';

  items: ObservedArray<YTNode>;
  view_all_text: Text;
  view_all_endpoint: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.items = Parser.parseArray(data.items);
    this.view_all_text = new Text(data.viewAllText);
    this.view_all_endpoint = new NavigationEndpoint(data.viewAllEndpoint);
  }

  // XXX: Alias for consistency.
  get contents() {
    return this.items;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/Video.ts:

import { timeToSeconds } from '../../utils/Utils.js';
import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import ExpandableMetadata from './ExpandableMetadata.js';
import MetadataBadge from './MetadataBadge.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import ThumbnailOverlayTimeStatus from './ThumbnailOverlayTimeStatus.js';
import Menu from './menus/Menu.js';
import Author from './misc/Author.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';

export default class Video extends YTNode {
  static type = 'Video';

  id: string;
  title: Text;
  description_snippet?: Text;
  snippets?: {
    text: Text;
    hover_text: Text;
  }[];
  expandable_metadata: ExpandableMetadata | null;
  thumbnails: Thumbnail[];
  thumbnail_overlays: ObservedArray<YTNode>;
  rich_thumbnail?: YTNode;
  author: Author;
  badges: MetadataBadge[];
  endpoint: NavigationEndpoint;
  published: Text;
  view_count: Text;
  short_view_count: Text;
  upcoming?: Date;
  duration: {
    text: string;
    seconds: number;
  };
  show_action_menu: boolean;
  is_watched: boolean;
  menu: Menu | null;
  search_video_result_entity_key?: string;

  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);

    if (Reflect.has(data, 'descriptionSnippet')) {
      this.description_snippet = new Text(data.descriptionSnippet);
    }

    if (Reflect.has(data, 'detailedMetadataSnippets')) {
      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);
    this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
    this.thumbnail_overlays = Parser.parseArray(data.thumbnailOverlays);

    if (Reflect.has(data, 'richThumbnail')) {
      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')) {
      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')) {
      this.search_video_result_entity_key = data.searchVideoResultEntityKey;
    }
  }

  get description(): string {
    if (this.snippets) {
      return this.snippets.map((snip) => snip.text.toString()).join('');
    }

    return this.description_snippet?.toString() || '';
  }

  get is_live(): boolean {
    return this.badges.some((badge) => {
      if (badge.style === 'BADGE_STYLE_TYPE_LIVE_NOW' || badge.label === 'LIVE')
        return true;
    }) || this.thumbnail_overlays.firstOfType(ThumbnailOverlayTimeStatus)?.style === 'LIVE';
  }

  get is_upcoming(): boolean | undefined {
    return this.upcoming && this.upcoming > new Date();
  }

  get is_premiere(): boolean {
    return this.badges.some((badge) => badge.label === 'PREMIERE');
  }

  get is_4k(): boolean {
    return this.badges.some((badge) => badge.label === '4K');
  }

  get has_captions(): boolean {
    return this.badges.some((badge) => badge.label === 'CC');
  }

  get best_thumbnail(): Thumbnail | undefined {
    return this.thumbnails[0];
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/VideoAttributeView.ts:

import { YTNode } from '../helpers.js';
import NavigationEndpoint from './NavigationEndpoint.js';

import ContentPreviewImageView from './ContentPreviewImageView.js';
import { Parser } from '../index.js';

import type { RawNode } from '../types/index.js';
import Thumbnail from './misc/Thumbnail.js';

export default class VideoAttributeView extends YTNode {
  static type = 'VideoAttributeView';

  image: ContentPreviewImageView | Thumbnail[] | null;
  image_style: string;
  title: string;
  subtitle: string;
  secondary_subtitle: {
    content: string
  };
  orientation: string;
  sizing_rule: string;
  overflow_menu_on_tap: NavigationEndpoint;
  overflow_menu_a11y_label: string;

  constructor(data: RawNode) {
    super();

    if (data.image?.sources) {
      this.image = Thumbnail.fromResponse(data.image);
    } else {
      this.image = Parser.parseItem(data.image, ContentPreviewImageView);
    }

    this.image_style = data.imageStyle;
    this.title = data.title;
    this.subtitle = data.subtitle;
    this.secondary_subtitle = {
      content: data.secondarySubtitle.content
    };
    this.orientation = data.orientation;
    this.sizing_rule = data.sizingRule;
    this.overflow_menu_on_tap = new NavigationEndpoint(data.overflowMenuOnTap);
    this.overflow_menu_a11y_label = data.overflowMenuA11yLabel;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/VideoAttributesSectionView.ts:

import { Parser, type RawNode } from '../index.js';
import { YTNode, type ObservedArray } from '../helpers.js';

import ButtonView from './ButtonView.js';
import VideoAttributeView from './VideoAttributeView.js';

export default class VideoAttributesSectionView extends YTNode {
  static type = 'VideoAttributesSectionView';

  header_title: string;
  header_subtitle: string;
  video_attributes: ObservedArray<VideoAttributeView>;
  previous_button: ButtonView | null;
  next_button: ButtonView | null;

  constructor(data: RawNode) {
    super();
    this.header_title = data.headerTitle;
    this.header_subtitle = data.headerSubtitle;
    this.video_attributes = Parser.parseArray(data.videoAttributeViewModels, VideoAttributeView);
    this.previous_button = Parser.parseItem(data.previousButton, ButtonView);
    this.next_button = Parser.parseItem(data.nextButton, ButtonView);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/VideoCard.ts:

import type { RawNode } from '../index.js';
import Video from './Video.js';

export default class VideoCard extends Video {
  static type = 'VideoCard';

  constructor(data: RawNode) {
    super(data);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/VideoDescriptionCourseSection.ts:

import type { ObservedArray } from '../helpers.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import { Parser } from '../index.js';
import StructuredDescriptionPlaylistLockup from './StructuredDescriptionPlaylistLockup.js';
import Text from './misc/Text.js';

export default class VideoDescriptionCourseSection extends YTNode {
  static type = 'VideoDescriptionCourseSection';

  section_title: Text;
  media_lockups: ObservedArray<StructuredDescriptionPlaylistLockup>;

  constructor(data: RawNode) {
    super();
    this.section_title = new Text(data.sectionTitle);
    this.media_lockups = Parser.parseArray(data.mediaLockups, [ StructuredDescriptionPlaylistLockup ]);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/VideoDescriptionHeader.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import { Text, Thumbnail } from '../misc.js';
import Factoid from './Factoid.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import UploadTimeFactoid from './UploadTimeFactoid.js';
import ViewCountFactoid from './ViewCountFactoid.js';

export default class VideoDescriptionHeader extends YTNode {
  static type = 'VideoDescriptionHeader';

  channel: Text;
  channel_navigation_endpoint?: NavigationEndpoint;
  channel_thumbnail: Thumbnail[];
  factoids: ObservedArray<Factoid | ViewCountFactoid | UploadTimeFactoid>;
  publish_date: Text;
  title: Text;
  views: Text;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.channel = new Text(data.channel);
    this.channel_navigation_endpoint = new NavigationEndpoint(data.channelNavigationEndpoint);
    this.channel_thumbnail = Thumbnail.fromResponse(data.channelThumbnail);
    this.publish_date = new Text(data.publishDate);
    this.views = new Text(data.views);
    this.factoids = Parser.parseArray(data.factoid, [ Factoid, ViewCountFactoid, UploadTimeFactoid ]);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/VideoDescriptionInfocardsSection.ts:

import { Parser, type RawNode } from '../index.js';

import { YTNode } from '../helpers.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';
import Button from './Button.js';
import NavigationEndpoint from './NavigationEndpoint.js';

export default class VideoDescriptionInfocardsSection extends YTNode {
  static type = 'VideoDescriptionInfocardsSection';

  section_title: Text;
  creator_videos_button: Button | null;
  creator_about_button: Button | null;
  section_subtitle: Text;
  channel_avatar: Thumbnail[];
  channel_endpoint: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.section_title = new Text(data.sectionTitle);
    this.creator_videos_button = Parser.parseItem(data.creatorVideosButton, Button);
    this.creator_about_button = Parser.parseItem(data.creatorAboutButton, Button);
    this.section_subtitle = new Text(data.sectionSubtitle);
    this.channel_avatar = Thumbnail.fromResponse(data.channelAvatar);
    this.channel_endpoint = new NavigationEndpoint(data.channelEndpoint);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/VideoDescriptionMusicSection.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import { Text } from '../misc.js';
import CarouselLockup from './CarouselLockup.js';

export default class VideoDescriptionMusicSection extends YTNode {
  static type = 'VideoDescriptionMusicSection';

  carousel_lockups: ObservedArray<CarouselLockup>;
  section_title: Text;

  constructor(data: RawNode) {
    super();
    this.carousel_lockups = Parser.parseArray(data.carouselLockups, CarouselLockup);
    this.section_title = new Text(data.sectionTitle);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/VideoDescriptionTranscriptSection.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import { Parser } from '../index.js';
import { Text } from '../misc.js';
import Button from './Button.js';

export default class VideoDescriptionTranscriptSection extends YTNode {
  static type = 'VideoDescriptionTranscriptSection';

  section_title: Text;
  sub_header_text: Text;
  primary_button: Button | null;

  constructor(data: RawNode) {
    super();
    this.section_title = new Text(data.sectionTitle);
    this.sub_header_text = new Text(data.subHeaderText);
    this.primary_button = Parser.parseItem(data.primaryButton, Button);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/VideoInfoCardContent.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';
import Thumbnail from './misc/Thumbnail.js';

export default class VideoInfoCardContent extends YTNode {
  static type = 'VideoInfoCardContent';

  title: Text;
  channel_name: Text;
  view_count: Text;
  video_thumbnails: Thumbnail[];
  duration: Text;
  endpoint: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.videoTitle);
    this.channel_name = new Text(data.channelName);
    this.view_count = new Text(data.viewCountText);
    this.video_thumbnails = Thumbnail.fromResponse(data.videoThumbnail);
    this.duration = new Text(data.lengthString);
    this.endpoint = new NavigationEndpoint(data.action);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/VideoOwner.ts:

import Text from './misc/Text.js';
import Author from './misc/Author.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';

export default class VideoOwner extends YTNode {
  static type = 'VideoOwner';

  subscription_button;
  subscriber_count: Text;
  author: Author;

  constructor(data: RawNode) {
    super();
    // TODO: check this
    this.subscription_button = data.subscriptionButton;
    this.subscriber_count = new Text(data.subscriberCountText);

    this.author = new Author({
      ...data.title,
      navigationEndpoint: data.navigationEndpoint
    }, data.badges, data.thumbnail);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/VideoPrimaryInfo.ts:

import type { ObservedArray } from '../helpers.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import { Parser } from '../index.js';
import MetadataBadge from './MetadataBadge.js';
import Menu from './menus/Menu.js';
import Text from './misc/Text.js';

export default class VideoPrimaryInfo extends YTNode {
  static type = 'VideoPrimaryInfo';

  title: Text;
  super_title_link?: Text;
  view_count: Text;
  short_view_count: Text;
  badges: ObservedArray<MetadataBadge>;
  published: Text;
  relative_date: Text;
  menu: Menu | null;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);

    if (Reflect.has(data, 'superTitleLink')) {
      this.super_title_link = new Text(data.superTitleLink);
    }

    this.view_count = new Text(data.viewCount?.videoViewCountRenderer?.viewCount);
    this.short_view_count = new Text(data.viewCount?.videoViewCountRenderer?.shortViewCount);
    this.badges = Parser.parseArray(data.badges, MetadataBadge);
    this.published = new Text(data.dateText);
    this.relative_date = new Text(data.relativeDateText);
    this.menu = Parser.parseItem(data.videoActions, Menu);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/VideoSecondaryInfo.ts:

import { Parser, type RawNode } from '../index.js';
import Text from './misc/Text.js';
import Button from './Button.js';
import VideoOwner from './VideoOwner.js';
import SubscribeButton from './SubscribeButton.js';
import MetadataRowContainer from './MetadataRowContainer.js';
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: string;
  show_less_text: string;
  default_expanded: string;
  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')) {
      this.description = Text.fromAttributed(data.attributedDescription);
    }

    this.subscribe_button = Parser.parseItem(data.subscribeButton, [ SubscribeButton, Button ]);
    this.metadata = Parser.parseItem(data.metadataRowContainer, MetadataRowContainer);
    this.show_more_text = data.showMoreText;
    this.show_less_text = data.showLessText;
    this.default_expanded = data.defaultExpanded;
    this.description_collapsed_lines = data.descriptionCollapsedLines;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ViewCountFactoid.ts:

import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import { Parser } from '../index.js';
import Factoid from './Factoid.js';

export default class ViewCountFactoid extends YTNode {
  static type = 'ViewCountFactoid';

  view_count_entity_key: string;
  factoid: Factoid | null;
  view_count_type: string;

  constructor(data: RawNode) {
    super();
    this.view_count_entity_key = data.viewCountEntityKey;
    this.factoid = Parser.parseItem(data.factoid, [ Factoid ]);
    this.view_count_type = data.viewCountType;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/WatchCardCompactVideo.ts:

import { timeToSeconds } from '../../utils/Utils.js';
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Text from './misc/Text.js';

export default class WatchCardCompactVideo extends YTNode {
  static type = 'WatchCardCompactVideo';

  title: Text;
  subtitle: Text;
  duration: {
    text: string;
    seconds: number;
  };
  style: string;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.subtitle = new Text(data.subtitle);

    this.duration = {
      text: new Text(data.lengthText).toString(),
      seconds: timeToSeconds(data.lengthText.simpleText)
    };

    this.style = data.style;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/WatchCardHeroVideo.ts:

import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';

export default class WatchCardHeroVideo extends YTNode {
  static type = 'WatchCardHeroVideo';

  endpoint: NavigationEndpoint;
  call_to_action_button: YTNode;
  hero_image: YTNode;
  label: string;

  constructor(data: RawNode) {
    super();
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    this.call_to_action_button = Parser.parseItem(data.callToActionButton);
    this.hero_image = Parser.parseItem(data.heroImage);
    this.label = data.lengthText?.accessibility.accessibilityData.label || '';
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/WatchCardRichHeader.ts:

import { YTNode } from '../helpers.js';
import { type RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Author from './misc/Author.js';
import Text from './misc/Text.js';

export default class WatchCardRichHeader extends YTNode {
  static type = 'WatchCardRichHeader';

  title: Text;
  title_endpoint: NavigationEndpoint;
  subtitle: Text;
  author: Author;
  style: string;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.title_endpoint = new NavigationEndpoint(data.titleNavigationEndpoint);
    this.subtitle = new Text(data.subtitle);
    this.author = new Author(data, data.titleBadge ? [ data.titleBadge ] : null, data.avatar);
    this.author.name = this.title.toString();
    this.style = data.style;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/WatchCardSectionSequence.ts:

import type { ObservedArray } from '../helpers.js';
import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';

export default class WatchCardSectionSequence extends YTNode {
  static type = 'WatchCardSectionSequence';

  lists: ObservedArray<YTNode>;

  constructor(data: RawNode) {
    super();
    this.lists = Parser.parseArray(data.lists);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/WatchNextEndScreen.ts:

import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import EndScreenPlaylist from './EndScreenPlaylist.js';
import EndScreenVideo from './EndScreenVideo.js';
import Text from './misc/Text.js';

export default class WatchNextEndScreen extends YTNode {
  static type = 'WatchNextEndScreen';

  results: ObservedArray<EndScreenVideo | EndScreenPlaylist>;
  title: string;

  constructor(data: RawNode) {
    super();
    this.results = Parser.parseArray(data.results, [ EndScreenVideo, EndScreenPlaylist ]);
    this.title = new Text(data.title).toString();
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/WatchNextTabbedResults.ts:

import type { RawNode } from '../index.js';
import TwoColumnBrowseResults from './TwoColumnBrowseResults.js';

export default class WatchNextTabbedResults extends TwoColumnBrowseResults {
  static type = 'WatchNextTabbedResults';

  constructor(data: RawNode) {
    super(data);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/YpcTrailer.ts:

import { YTNode } from '../helpers.js';
import type { IRawResponse, RawNode } from '../index.js';

export default class YpcTrailer extends YTNode {
  static type = 'YpcTrailer';

  video_message: string;
  player_response: IRawResponse;

  constructor(data: RawNode) {
    super();
    this.video_message = data.fullVideoMessage;
    this.player_response = data.unserializedPlayerResponse;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/actions/AppendContinuationItemsAction.ts:

import { Parser } from '../../index.js';
import type { RawNode } from '../../index.js';
import type { ObservedArray } from '../../helpers.js';
import { YTNode } from '../../helpers.js';

export default class AppendContinuationItemsAction extends YTNode {
  static type = 'AppendContinuationItemsAction';

  contents: ObservedArray<YTNode> | null;
  target: string;

  constructor(data: RawNode) {
    super();
    this.contents = Parser.parseArray(data.continuationItems);
    this.target = data.target;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/actions/OpenPopupAction.ts:

import { Parser } from '../../index.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class OpenPopupAction extends YTNode {
  static type = 'OpenPopupAction';

  popup: YTNode;
  popup_type: string;

  constructor(data: RawNode) {
    super();
    this.popup = Parser.parseItem(data.popup);
    this.popup_type = data.popupType;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/actions/UpdateEngagementPanelAction.ts:

import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';
import { Parser } from '../../index.js';
import Transcript from '../Transcript.js';

export default class UpdateEngagementPanelAction extends YTNode {
  static type = 'UpdateEngagementPanelAction';

  target_id: string;
  content: Transcript | null;

  constructor(data: RawNode) {
    super();
    this.target_id = data.targetId;
    this.content = Parser.parseItem(data.content, Transcript);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/analytics/AnalyticsMainAppKeyMetrics.ts:

import DataModelSection from './DataModelSection.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class AnalyticsMainAppKeyMetrics extends YTNode {
  static type = 'AnalyticsMainAppKeyMetrics';

  period: string;
  sections: DataModelSection[];

  constructor(data: RawNode) {
    super();
    this.period = data.cardData.periodLabel;

    const metrics_data = data.cardData.sections[0].analyticsKeyMetricsData;

    this.sections = metrics_data.dataModel.sections.map(
      (section: any) => new DataModelSection(section)
    );
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/analytics/AnalyticsRoot.ts:

import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class AnalyticsRoot extends YTNode {
  static type = 'AnalyticsRoot';

  title: string;
  selected_card_index_key: string;
  use_main_app_specs: boolean;

  table_cards: {
    title: string;
    rows: {
      label: string;
      display_value: string;
      display_value_a11y: string;
      bar_ratio: number;
      bar_color: number;
      bar_opacity: number;
    }[];
  }[];

  constructor(data: RawNode) {
    super();
    const cards = data.analyticsTableCarouselData.data.tableCards;

    this.title = data.analyticsTableCarouselData.carouselTitle;
    this.selected_card_index_key = data.analyticsTableCarouselData.selectedCardIndexKey;

    this.table_cards = cards.map((card: any) => ({
      title: card.cardData.title,
      rows: card.cardData.rows.map((row: any) => ({
        label: row.label,
        display_value: row.displayValue,
        display_value_a11y: row.displayValueA11y,
        bar_ratio: row.barRatio,
        bar_color: row.barColor,
        bar_opacity: row.barOpacity
      }))
    }));

    this.use_main_app_specs = data.analyticsTableCarouselData.useMainAppSpecs;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/analytics/AnalyticsShortsCarouselCard.ts:

import NavigationEndpoint from '../NavigationEndpoint.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class AnalyticsShortsCarouselCard extends YTNode {
  static type = 'AnalyticsShortsCarouselCard';

  title: string;
  shorts: {
    description: string;
    thumbnail_url: string;
    endpoint: NavigationEndpoint;
  }[];

  constructor(data: RawNode) {
    super();
    this.title = data.title;
    this.shorts = data.shortsCarouselData.shorts.map((short: any) => ({
      description: short.shortsDescription,
      thumbnail_url: short.thumbnailUrl,
      endpoint: new NavigationEndpoint(short.videoEndpoint)
    }));
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/analytics/AnalyticsVideo.ts:

import Thumbnail from '../misc/Thumbnail.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class AnalyticsVideo extends YTNode {
  static type = 'AnalyticsVideo';

  title: string;
  metadata: {
    views: string;
    published: string;
    thumbnails: Thumbnail[];
    duration: string;
    is_short: boolean;
  };

  constructor(data: RawNode) {
    super();
    this.title = data.videoTitle;

    this.metadata = {
      views: data.videoDescription.split('Β·')[0].trim(),
      published: data.videoDescription.split('Β·')[1].trim(),
      thumbnails: Thumbnail.fromResponse(data.thumbnailDetails),
      duration: data.formattedLength,
      is_short: data.isShort
    };
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/analytics/AnalyticsVodCarouselCard.ts:

import Video from './AnalyticsVideo.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class AnalyticsVodCarouselCard extends YTNode {
  static type = 'AnalyticsVodCarouselCard';

  title: string;
  videos?: Video[];
  no_data_message?: string;

  constructor(data: RawNode) {
    super();
    this.title = data.title;

    if (Reflect.has(data, 'noDataMessage')) {
      this.no_data_message = data.noDataMessage;
    }

    if (Reflect.has(data, 'videoCarouselData') && Reflect.has(data.videoCarouselData, 'videos')) {
      this.videos = data.videoCarouselData.videos.map((video: RawNode) => new Video(video));
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/analytics/CtaGoToCreatorStudio.ts:

import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class CtaGoToCreatorStudio extends YTNode {
  static type = 'CtaGoToCreatorStudio';

  title: string;
  use_new_specs: boolean;

  constructor(data: RawNode) {
    super();
    this.title = data.buttonLabel;
    this.use_new_specs = data.useNewSpecs;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/analytics/DataModelSection.ts:

import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class DataModelSection extends YTNode {
  static type = 'DataModelSection';

  title: string;
  subtitle: string;
  metric_value: string;

  comparison_indicator: {
    trend: string;
  };

  series_configuration: {
    line_series: {
      lines_data: {
        x: number[];
        y: number[];
        style: {
          line_width: number;
          line_color: number;
        }
      }
      domain_axis: {
        tick_values: number[];
        custom_formatter: {
          labels: string[];
        }
      }
      measure_axis: {
        tick_values: number[];
        custom_formatter: {
          labels: string[];
        }
      }
    }
  };

  constructor(data: RawNode) {
    super();

    this.title = data.title;
    this.subtitle = data.subtitle;
    this.metric_value = data.metricValue;
    this.comparison_indicator = data.comparisonIndicator;

    const line_series = data.seriesConfiguration.lineSeries;

    this.series_configuration = {
      line_series: {
        lines_data: {
          x: line_series.linesData[0].x,
          y: line_series.linesData[0].y,
          style: {
            line_width: line_series.linesData[0].style.lineWidth,
            line_color: line_series.linesData[0].style.lineColor
          }
        },
        domain_axis: {
          tick_values: line_series.domainAxis.tickValues,
          custom_formatter: line_series.domainAxis.customFormatter
        },
        measure_axis: {
          tick_values: line_series.measureAxis.tickValues,
          custom_formatter: line_series.measureAxis.customFormatter
        }
      }
    };
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/analytics/StatRow.ts:

import Text from '../misc/Text.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class StatRow extends YTNode {
  static type = 'StatRow';

  title: Text;
  contents: Text;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.contents = new Text(data.contents);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/comments/AuthorCommentBadge.ts:

import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class AuthorCommentBadge extends YTNode {
  static type = 'AuthorCommentBadge';

  #data;

  icon_type?: string;
  tooltip: string;
  style?: string;

  constructor(data: RawNode) {
    super();

    if (Reflect.has(data, 'icon') && Reflect.has(data.icon, 'iconType')) {
      this.icon_type = data.icon.iconType;
    }

    this.tooltip = data.iconTooltip;

    // *** For consistency
    this.tooltip === 'Verified' &&
      (this.style = 'BADGE_STYLE_TYPE_VERIFIED') &&
      (data.style = 'BADGE_STYLE_TYPE_VERIFIED');

    this.#data = data;
  }

  get orig_badge() {
    return this.#data;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/comments/Comment.ts:

import { Parser } from '../../index.js';

import Author from '../misc/Author.js';
import Text from '../misc/Text.js';
import Thumbnail from '../misc/Thumbnail.js';

import Menu from '../menus/Menu.js';
import AuthorCommentBadge from './AuthorCommentBadge.js';
import CommentActionButtons from './CommentActionButtons.js';
import CommentReplyDialog from './CommentReplyDialog.js';
import PdgCommentChip from './PdgCommentChip.js';
import SponsorCommentBadge from './SponsorCommentBadge.js';

import * as Proto from '../../../proto/index.js';
import { InnertubeError } from '../../../utils/Utils.js';
import { YTNode } from '../../helpers.js';

import type Actions from '../../../core/Actions.js';
import type { ApiResponse } from '../../../core/Actions.js';
import type { RawNode } from '../../index.js';

export default class Comment extends YTNode {
  static type = 'Comment';

  #actions?: Actions;

  content: Text;
  published: Text;
  author_is_channel_owner: boolean;
  current_user_reply_thumbnail: Thumbnail[];
  sponsor_comment_badge: SponsorCommentBadge | null;
  paid_comment_chip: PdgCommentChip | null;
  author_badge: AuthorCommentBadge | null;
  author: Author;
  action_menu: Menu | null;
  action_buttons: CommentActionButtons | null;
  comment_id: string;
  vote_status: string;
  vote_count: string;
  reply_count: number;
  is_liked: boolean;
  is_disliked: boolean;
  is_hearted: boolean;
  is_pinned: boolean;
  is_member: boolean;

  constructor(data: RawNode) {
    super();
    this.content = new Text(data.contentText);
    this.published = new Text(data.publishedTimeText);
    this.author_is_channel_owner = data.authorIsChannelOwner;
    this.current_user_reply_thumbnail = Thumbnail.fromResponse(data.currentUserReplyThumbnail);
    this.sponsor_comment_badge = Parser.parseItem(data.sponsorCommentBadge, SponsorCommentBadge);
    this.paid_comment_chip = Parser.parseItem(data.paidCommentChipRenderer, PdgCommentChip);
    this.author_badge = Parser.parseItem(data.authorCommentBadge, AuthorCommentBadge);

    this.author = new Author({
      ...data.authorText,
      navigationEndpoint: data.authorEndpoint
    }, this.author_badge ? [ {
      metadataBadgeRenderer: this.author_badge?.orig_badge
    } ] : null, data.authorThumbnail);

    this.action_menu = Parser.parseItem(data.actionMenu, Menu);
    this.action_buttons = Parser.parseItem(data.actionButtons, CommentActionButtons);
    this.comment_id = data.commentId;
    this.vote_status = data.voteStatus;

    this.vote_count = data.voteCount ? new Text(data.voteCount).toString() : '0';

    this.reply_count = data.replyCount || 0;
    this.is_liked = !!this.action_buttons?.like_button?.is_toggled;
    this.is_disliked = !!this.action_buttons?.dislike_button?.is_toggled;
    this.is_hearted = !!this.action_buttons?.creator_heart?.is_hearted;
    this.is_pinned = !!data.pinnedCommentBadge;
    this.is_member = !!data.sponsorCommentBadge;
  }

  /**
   * Likes the comment.
   */
  async like(): Promise<ApiResponse> {
    if (!this.#actions)
      throw new InnertubeError('An active caller must be provide to perform this operation.');

    const button = this.action_buttons?.like_button;

    if (!button)
      throw new InnertubeError('Like button was not found.', { comment_id: this.comment_id });

    if (button.is_toggled)
      throw new InnertubeError('This comment is already liked', { comment_id: this.comment_id });

    const response = await button.endpoint.call(this.#actions, { parse: false });

    return response;
  }
  /**
   * Dislikes the comment.
   */
  async dislike(): Promise<ApiResponse> {
    if (!this.#actions)
      throw new InnertubeError('An active caller must be provide to perform this operation.');

    const button = this.action_buttons?.dislike_button;

    if (!button)
      throw new InnertubeError('Dislike button was not found.', { comment_id: this.comment_id });

    if (button.is_toggled)
      throw new InnertubeError('This comment is already disliked', { comment_id: this.comment_id });

    const response = await button.endpoint.call(this.#actions, { parse: false });

    return response;
  }

  /**
   * Creates a reply to the comment.
   */
  async reply(text: string): Promise<ApiResponse> {
    if (!this.#actions)
      throw new InnertubeError('An active caller must be provide to perform this operation.');

    if (!this.action_buttons?.reply_button)
      throw new InnertubeError('Cannot reply to another reply. Try mentioning the user instead.', { comment_id: this.comment_id });

    const button = this.action_buttons?.reply_button;

    if (!button.endpoint?.dialog)
      throw new InnertubeError('Reply button endpoint did not have a dialog.');

    const dialog = button.endpoint.dialog.as(CommentReplyDialog);
    const dialog_button = dialog.reply_button;

    if (!dialog_button)
      throw new InnertubeError('Reply button was not found in the dialog.', { comment_id: this.comment_id });

    if (!dialog_button.endpoint)
      throw new InnertubeError('Reply button endpoint was not found.', { comment_id: this.comment_id });

    const response = await dialog_button.endpoint.call(this.#actions, { commentText: text });

    return response;
  }

  /**
   * Translates the comment to a given language.
   * @param target_language - Ex; en, ja
   */
  async translate(target_language: string): Promise<{
    content: any;
    success: boolean;
    status_code: number;
    data: any;
  }> {
    if (!this.#actions)
      throw new InnertubeError('An active caller must be provide to perform this operation.');

    // Emojis must be removed otherwise InnerTube throws a 400 status code at us.
    const text = this.content.toString().replace(/[^\p{L}\p{N}\p{P}\p{Z}]/gu, '');

    const payload = {
      text,
      target_language,
      comment_id: this.comment_id
    };

    const action = Proto.encodeCommentActionParams(22, payload);
    const response = await this.#actions.execute('comment/perform_comment_action', { action, client: 'ANDROID' });

    // XXX: Should move this to Parser#parseResponse
    const mutations = response.data.frameworkUpdates?.entityBatchUpdate?.mutations;
    const content = mutations?.[0]?.payload?.commentEntityPayload?.translatedContent?.content;

    return { ...response, content };
  }

  setActions(actions: Actions | undefined) {
    this.#actions = actions;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/comments/CommentActionButtons.ts:

import { Parser } from '../../index.js';
import Button from '../Button.js';
import ToggleButton from '../ToggleButton.js';
import CreatorHeart from './CreatorHeart.js';

import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class CommentActionButtons extends YTNode {
  static type = 'CommentActionButtons';

  like_button: ToggleButton | null;
  dislike_button: ToggleButton | null;
  reply_button: Button | null;
  creator_heart: CreatorHeart | null;

  constructor(data: RawNode) {
    super();
    this.like_button = Parser.parseItem(data.likeButton, ToggleButton);
    this.dislike_button = Parser.parseItem(data.dislikeButton, ToggleButton);
    this.reply_button = Parser.parseItem(data.replyButton, Button);
    this.creator_heart = Parser.parseItem(data.creatorHeart, CreatorHeart);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/comments/CommentDialog.ts:

import { Parser } from '../../index.js';
import Button from '../Button.js';
import Text from '../misc/Text.js';
import Thumbnail from '../misc/Thumbnail.js';
import EmojiPicker from './EmojiPicker.js';

import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class CommentDialog extends YTNode {
  static type = 'CommentDialog';

  editable_text: Text;
  author_thumbnail: Thumbnail[];
  submit_button: Button | null;
  cancel_button: Button | null;
  placeholder: Text;
  emoji_button: Button | null;
  emoji_picker: EmojiPicker | null;

  constructor(data: RawNode) {
    super();
    this.editable_text = new Text(data.editableText);
    this.author_thumbnail = Thumbnail.fromResponse(data.authorThumbnail);
    this.submit_button = Parser.parseItem(data.submitButton, Button);
    this.cancel_button = Parser.parseItem(data.cancelButton, Button);
    this.placeholder = new Text(data.placeholderText);
    this.emoji_button = Parser.parseItem(data.emojiButton, Button);
    this.emoji_picker = Parser.parseItem(data.emojiPicker, EmojiPicker);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/comments/CommentReplies.ts:

import { Parser } from '../../index.js';
import Button from '../Button.js';
import Thumbnail from '../misc/Thumbnail.js';

import { YTNode, type ObservedArray } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class CommentReplies extends YTNode {
  static type = 'CommentReplies';

  contents: ObservedArray<YTNode>;
  view_replies: Button | null;
  hide_replies: Button | null;
  view_replies_creator_thumbnail: Thumbnail[];
  has_channel_owner_replied: boolean;

  constructor(data: RawNode) {
    super();
    this.contents = Parser.parseArray(data.contents);
    this.view_replies = Parser.parseItem(data.viewReplies, Button);
    this.hide_replies = Parser.parseItem(data.hideReplies, Button);
    this.view_replies_creator_thumbnail = Thumbnail.fromResponse(data.viewRepliesCreatorThumbnail);
    this.has_channel_owner_replied = !!data.viewRepliesCreatorThumbnail;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/comments/CommentReplyDialog.ts:

import { Parser } from '../../index.js';
import Button from '../Button.js';
import Text from '../misc/Text.js';
import Thumbnail from '../misc/Thumbnail.js';

import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class CommentReplyDialog extends YTNode {
  static type = 'CommentReplyDialog';

  reply_button: Button | null;
  cancel_button: Button | null;
  author_thumbnail: Thumbnail[];
  placeholder: Text;
  error_message: Text;

  constructor(data: RawNode) {
    super();
    this.reply_button = Parser.parseItem(data.replyButton, Button);
    this.cancel_button = Parser.parseItem(data.cancelButton, Button);
    this.author_thumbnail = Thumbnail.fromResponse(data.authorThumbnail);
    this.placeholder = new Text(data.placeholderText);
    this.error_message = new Text(data.errorMessage);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/comments/CommentSimplebox.ts:

import { Parser } from '../../index.js';
import Button from '../Button.js';
import Text from '../misc/Text.js';
import Thumbnail from '../misc/Thumbnail.js';

import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class CommentSimplebox extends YTNode {
  static type = 'CommentSimplebox';

  submit_button: Button | null;
  cancel_button: Button | null;
  author_thumbnail: Thumbnail[];
  placeholder: Text;
  avatar_size: string;

  constructor(data: RawNode) {
    super();
    this.submit_button = Parser.parseItem(data.submitButton, Button);
    this.cancel_button = Parser.parseItem(data.cancelButton, Button);
    this.author_thumbnail = Thumbnail.fromResponse(data.authorThumbnail);
    this.placeholder = new Text(data.placeholderText);
    this.avatar_size = data.avatarSize;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/comments/CommentThread.ts:

import { Parser } from '../../index.js';
import Button from '../Button.js';
import ContinuationItem from '../ContinuationItem.js';
import Comment from './Comment.js';
import CommentView from './CommentView.js';
import CommentReplies from './CommentReplies.js';

import { InnertubeError } from '../../../utils/Utils.js';
import { observe, YTNode } from '../../helpers.js';

import type Actions from '../../../core/Actions.js';
import type { ObservedArray } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class CommentThread extends YTNode {
  static type = 'CommentThread';

  #actions?: Actions;
  #continuation?: ContinuationItem;

  comment: Comment | CommentView | null;
  replies?: ObservedArray<Comment | CommentView>;
  comment_replies_data: CommentReplies | null;
  is_moderated_elq_comment: boolean;
  has_replies: boolean;

  constructor(data: RawNode) {
    super();

    if (Reflect.has(data, 'commentViewModel')) {
      this.comment = Parser.parseItem(data.commentViewModel, CommentView);
    } else {
      this.comment = Parser.parseItem(data.comment, Comment);
    }
    this.comment_replies_data = Parser.parseItem(data.replies, CommentReplies);
    this.is_moderated_elq_comment = data.isModeratedElqComment;
    this.has_replies = !!this.comment_replies_data;
  }

  /**
   * Retrieves replies to this comment thread.
   */
  async getReplies(): Promise<CommentThread> {
    if (!this.#actions)
      throw new InnertubeError('Actions instance not set for this thread.');

    if (!this.comment_replies_data)
      throw new InnertubeError('This comment has no replies.', this);

    const continuation = this.comment_replies_data.contents?.firstOfType(ContinuationItem);

    if (!continuation)
      throw new InnertubeError('Replies continuation not found.');

    const response = await continuation.endpoint.call(this.#actions, { parse: true });

    if (!response.on_response_received_endpoints_memo)
      throw new InnertubeError('Unexpected response.', response);

    this.replies = observe(response.on_response_received_endpoints_memo.getType(Comment, CommentView).map((comment) => {
      comment.setActions(this.#actions);
      return comment;
    }));

    this.#continuation = response?.on_response_received_endpoints_memo.getType(ContinuationItem).first();

    return this;
  }

  /**
   * Retrieves next batch of replies.
   */
  async getContinuation(): Promise<CommentThread> {
    if (!this.replies)
      throw new InnertubeError('Cannot retrieve continuation because this thread\'s replies have not been loaded.');

    if (!this.#continuation)
      throw new InnertubeError('Continuation not found.');

    if (!this.#actions)
      throw new InnertubeError('Actions instance not set for this thread.');

    const load_more_button = this.#continuation.button?.as(Button);

    if (!load_more_button)
      throw new InnertubeError('"Load more" button not found.');

    const response = await load_more_button.endpoint.call(this.#actions, { parse: true });

    if (!response.on_response_received_endpoints_memo)
      throw new InnertubeError('Unexpected response.', response);

    this.replies = observe(response.on_response_received_endpoints_memo.getType(Comment, CommentView).map((comment) => {
      comment.setActions(this.#actions);
      return comment;
    }));

    this.#continuation = response.on_response_received_endpoints_memo.getType(ContinuationItem).first();

    return this;
  }

  get has_continuation(): boolean {
    if (!this.replies)
      throw new InnertubeError('Cannot determine if there is a continuation because this thread\'s replies have not been loaded.');
    return !!this.#continuation;
  }

  setActions(actions: Actions) {
    this.#actions = actions;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/comments/CommentView.ts:

import { YTNode } from '../../helpers.js';
import NavigationEndpoint from '../NavigationEndpoint.js';
import Author from '../misc/Author.js';
import Text from '../misc/Text.js';
import CommentReplyDialog from './CommentReplyDialog.js';
import { InnertubeError } from '../../../utils/Utils.js';
import * as Proto from '../../../proto/index.js';

import type Actions from '../../../core/Actions.js';
import type { ApiResponse } from '../../../core/Actions.js';
import type { RawNode } from '../../index.js';

export default class CommentView extends YTNode {
  static type = 'CommentView';

  #actions?: Actions;

  like_command?: NavigationEndpoint;
  dislike_command?: NavigationEndpoint;
  unlike_command?: NavigationEndpoint;
  undislike_command?: NavigationEndpoint;
  reply_command?: NavigationEndpoint;

  comment_id: string;
  is_pinned: boolean;
  keys: {
    comment: string;
    comment_surface: string;
    toolbar_state: string;
    toolbar_surface: string;
    shared: string;
  };

  content?: Text;
  published_time?: string;
  author_is_channel_owner?: boolean;
  like_count?: string;
  reply_count?: string;
  is_member?: boolean;
  member_badge?: {
    url: string,
    a11y: string;
  };
  author?: Author;

  is_liked?: boolean;
  is_disliked?: boolean;
  is_hearted?: boolean;

  constructor(data: RawNode) {
    super();

    this.comment_id = data.commentId;
    this.is_pinned = !!data.pinnedText;

    this.keys = {
      comment: data.commentKey,
      comment_surface: data.commentSurfaceKey,
      toolbar_state: data.toolbarStateKey,
      toolbar_surface: data.toolbarSurfaceKey,
      shared: data.sharedKey
    };
  }

  applyMutations(comment?: RawNode, toolbar_state?: RawNode, toolbar_surface?: RawNode) {
    if (comment) {
      this.content = Text.fromAttributed(comment.properties.content);
      this.published_time = comment.properties.publishedTime;
      this.author_is_channel_owner = !!comment.author.isCreator;

      this.like_count = comment.toolbar.likeCountNotliked ? comment.toolbar.likeCountNotliked : '0';
      this.reply_count = comment.toolbar.replyCount ? comment.toolbar.replyCount : '0';

      this.is_member = !!comment.author.sponsorBadgeUrl;

      if (Reflect.has(comment.author, 'sponsorBadgeUrl')) {
        this.member_badge = {
          url: comment.author.sponsorBadgeUrl,
          a11y: comment.author.A11y
        };
      }

      this.author = new Author({
        simpleText: comment.author.displayName,
        navigationEndpoint: comment.avatar.endpoint
      }, comment.author, comment.avatar.image, comment.author.channelId);
    }

    if (toolbar_state) {
      this.is_hearted = toolbar_state.heartState === 'TOOLBAR_HEART_STATE_HEARTED';
      this.is_liked = toolbar_state.likeState === 'TOOLBAR_LIKE_STATE_LIKED';
      this.is_disliked = toolbar_state.likeState === 'TOOLBAR_LIKE_STATE_DISLIKED';
    }

    if (toolbar_surface && !Reflect.has(toolbar_surface, 'prepareAccountCommand')) {
      this.like_command = new NavigationEndpoint(toolbar_surface.likeCommand);
      this.dislike_command = new NavigationEndpoint(toolbar_surface.dislikeCommand);
      this.unlike_command = new NavigationEndpoint(toolbar_surface.unlikeCommand);
      this.undislike_command = new NavigationEndpoint(toolbar_surface.undislikeCommand);
      this.reply_command = new NavigationEndpoint(toolbar_surface.replyCommand);
    }
  }

  /**
   * Likes the comment.
   * @returns A promise that resolves to the API response.
   * @throws If the Actions instance is not set for this comment or if the like command is not found.
   */
  async like(): Promise<ApiResponse> {
    if (!this.#actions)
      throw new InnertubeError('Actions instance not set for this comment.');

    if (!this.like_command)
      throw new InnertubeError('Like command not found.');

    if (this.is_liked)
      throw new InnertubeError('This comment is already liked.', { comment_id: this.comment_id });

    return this.like_command.call(this.#actions);
  }

  /**
   * Dislikes the comment.
   * @returns A promise that resolves to the API response.
   * @throws If the Actions instance is not set for this comment or if the dislike command is not found.
   */
  async dislike(): Promise<ApiResponse> {
    if (!this.#actions)
      throw new InnertubeError('Actions instance not set for this comment.');

    if (!this.dislike_command)
      throw new InnertubeError('Dislike command not found.');

    if (this.is_disliked)
      throw new InnertubeError('This comment is already disliked.', { comment_id: this.comment_id });

    return this.dislike_command.call(this.#actions);
  }

  /**
   * Unlikes the comment.
   * @returns A promise that resolves to the API response.
   * @throws If the Actions instance is not set for this comment or if the unlike command is not found.
   */
  async unlike(): Promise<ApiResponse> {
    if (!this.#actions)
      throw new InnertubeError('Actions instance not set for this comment.');

    if (!this.unlike_command)
      throw new InnertubeError('Unlike command not found.');

    if (!this.is_liked)
      throw new InnertubeError('This comment is not liked.', { comment_id: this.comment_id });

    return this.unlike_command.call(this.#actions);
  }

  /**
   * Undislikes the comment.
   * @returns A promise that resolves to the API response.
   * @throws If the Actions instance is not set for this comment or if the undislike command is not found.
   */
  async undislike(): Promise<ApiResponse> {
    if (!this.#actions)
      throw new InnertubeError('Actions instance not set for this comment.');

    if (!this.undislike_command)
      throw new InnertubeError('Undislike command not found.');

    if (!this.is_disliked)
      throw new InnertubeError('This comment is not disliked.', { comment_id: this.comment_id });

    return this.undislike_command.call(this.#actions);
  }

  /**
   * Replies to the comment.
   * @param comment_text - The text of the reply.
   * @returns A promise that resolves to the API response.
   * @throws If the Actions instance is not set for this comment or if the reply command is not found.
   */
  async reply(comment_text: string): Promise<ApiResponse> {
    if (!this.#actions)
      throw new InnertubeError('Actions instance not set for this comment.');

    if (!this.reply_command)
      throw new InnertubeError('Reply command not found.');

    const dialog = this.reply_command.dialog?.as(CommentReplyDialog);

    if (!dialog)
      throw new InnertubeError('Reply dialog not found.');

    const reply_button = dialog.reply_button;

    if (!reply_button)
      throw new InnertubeError('Reply button not found in the dialog.');

    if (!reply_button.endpoint)
      throw new InnertubeError('Reply button endpoint not found.');

    return reply_button.endpoint.call(this.#actions, { commentText: comment_text });
  }

  /**
   * Translates the comment to the specified target language.
   * @param target_language - The target language to translate the comment to, e.g. 'en', 'ja'.
   * @returns A Promise that resolves to an ApiResponse object with the translated content, if available.
   * @throws if the Actions instance is not set for this comment or if the comment content is not found.
   */
  async translate(target_language: string): Promise<ApiResponse & { content?: string }> {
    if (!this.#actions)
      throw new InnertubeError('Actions instance not set for this comment.');

    if (!this.content)
      throw new InnertubeError('Comment content not found.', { comment_id: this.comment_id });

    // Emojis must be removed otherwise InnerTube throws a 400 status code at us.
    const text = this.content.toString().replace(/[^\p{L}\p{N}\p{P}\p{Z}]/gu, '');

    const payload = {
      text,
      target_language
    };

    const action = Proto.encodeCommentActionParams(22, payload);
    const response = await this.#actions.execute('comment/perform_comment_action', { action, client: 'ANDROID' });

    // XXX: Should move this to Parser#parseResponse
    const mutations = response.data.frameworkUpdates?.entityBatchUpdate?.mutations;
    const content = mutations?.[0]?.payload?.commentEntityPayload?.translatedContent?.content;

    return { ...response, content };
  }

  setActions(actions: Actions | undefined) {
    this.#actions = actions;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/comments/CommentsEntryPointHeader.ts:

import Text from '../misc/Text.js';
import Thumbnail from '../misc/Thumbnail.js';
import CommentsSimplebox from './CommentsSimplebox.js';
import CommentsEntryPointTeaser from './CommentsEntryPointTeaser.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';
import { Parser } from '../../index.js';

export default class CommentsEntryPointHeader extends YTNode {
  static type = 'CommentsEntryPointHeader';

  header?: Text;
  comment_count?: Text;
  teaser_avatar?: Thumbnail[];
  teaser_content?: Text;
  content_renderer?: CommentsEntryPointTeaser | CommentsSimplebox | null;
  simplebox_placeholder?: Text;

  constructor(data: RawNode) {
    super();

    if (Reflect.has(data, 'headerText')) {
      this.header = new Text(data.headerText);
    }

    if (Reflect.has(data, 'commentCount')) {
      this.comment_count = new Text(data.commentCount);
    }

    if (Reflect.has(data, 'teaserAvatar') || Reflect.has(data, 'simpleboxAvatar')) {
      this.teaser_avatar = Thumbnail.fromResponse(data.teaserAvatar || data.simpleboxAvatar);
    }

    if (Reflect.has(data, 'teaserContent')) {
      this.teaser_content = new Text(data.teaserContent);
    }

    if (Reflect.has(data, 'contentRenderer')) {
      this.content_renderer = Parser.parseItem(data.contentRenderer, [ CommentsEntryPointTeaser, CommentsSimplebox ]);
    }

    if (Reflect.has(data, 'simpleboxPlaceholder')) {
      this.simplebox_placeholder = new Text(data.simpleboxPlaceholder);
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/comments/CommentsEntryPointTeaser.ts:

import Text from '../misc/Text.js';
import Thumbnail from '../misc/Thumbnail.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class CommentsEntryPointTeaser extends YTNode {
  static type = 'CommentsEntryPointTeaser';

  teaser_avatar?: Thumbnail[];
  teaser_content?: Text;

  constructor(data: RawNode) {
    super();

    if (Reflect.has(data, 'teaserAvatar')) {
      this.teaser_avatar = Thumbnail.fromResponse(data.teaserAvatar);
    }

    if (Reflect.has(data, 'teaserContent')) {
      this.teaser_content = new Text(data.teaserContent);
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/comments/CommentsHeader.ts:

import { Parser } from '../../index.js';
import SortFilterSubMenu from '../SortFilterSubMenu.js';
import Text from '../misc/Text.js';
import Thumbnail from '../misc/Thumbnail.js';

import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class CommentsHeader extends YTNode {
  static type = 'CommentsHeader';

  title: Text;
  count: Text;
  comments_count: Text;
  create_renderer;
  sort_menu: SortFilterSubMenu | null;

  custom_emojis?: {
    emoji_id: string;
    shortcuts: string[];
    search_terms: string[];
    image: Thumbnail[];
    is_custom_emoji: boolean;
  }[];

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.titleText);
    this.count = new Text(data.countText);
    this.comments_count = new Text(data.commentsCount);
    this.create_renderer = Parser.parseItem(data.createRenderer);
    this.sort_menu = Parser.parseItem(data.sortMenu, SortFilterSubMenu);

    if (Reflect.has(data, 'customEmojis')) {
      this.custom_emojis = data.customEmojis.map((emoji: RawNode) => {
        return {
          emoji_id: emoji.emojiId,
          shortcuts: emoji.shortcuts,
          search_terms: emoji.searchTerms,
          image: Thumbnail.fromResponse(emoji.image),
          is_custom_emoji: emoji.isCustomEmoji
        };
      });
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/comments/CommentsSimplebox.ts:

import { YTNode } from '../../helpers.js';
import Text from '../misc/Text.js';
import Thumbnail from '../misc/Thumbnail.js';
import type { RawNode } from '../../index.js';

export default class CommentsSimplebox extends YTNode {
  static type = 'CommentsSimplebox';

  simplebox_avatar: Thumbnail[];
  simplebox_placeholder: Text;

  constructor(data: RawNode) {
    super();
    this.simplebox_avatar = Thumbnail.fromResponse(data.simpleboxAvatar);
    this.simplebox_placeholder = new Text(data.simpleboxPlaceholder);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/comments/CreatorHeart.ts:

import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';
import Thumbnail from '../misc/Thumbnail.js';

export default class CreatorHeart extends YTNode {
  static type = 'CreatorHeart';

  creator_thumbnail: Thumbnail[];
  heart_icon_type?: string;
  heart_color: {
    basic_color_palette_data: {
      foreground_title_color: string;
    }
  };
  hearted_tooltip: string;
  is_hearted: boolean;
  is_enabled: boolean;
  kennedy_heart_color_string: string;

  constructor(data: RawNode) {
    super();
    this.creator_thumbnail = Thumbnail.fromResponse(data.creatorThumbnail);

    if (Reflect.has(data, 'heartIcon') && Reflect.has(data.heartIcon, 'iconType')) {
      this.heart_icon_type = data.heartIcon.iconType;
    }

    this.heart_color = {
      basic_color_palette_data: {
        foreground_title_color: data.heartColor?.basicColorPaletteData?.foregroundTitleColor
      }
    };

    this.hearted_tooltip = data.heartedTooltip;
    this.is_hearted = data.isHearted;
    this.is_enabled = data.isEnabled;
    this.kennedy_heart_color_string = data.kennedyHeartColorString;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/comments/EmojiPicker.ts:

import { type ObservedArray, YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';
import { Parser } from '../../index.js';
import Text from '../misc/Text.js';

export default class EmojiPicker extends YTNode {
  static type = 'EmojiPicker';

  id: string;
  categories: ObservedArray<YTNode>;
  category_buttons: ObservedArray<YTNode>;
  search_placeholder: Text;
  search_no_results: Text;
  pick_skin_tone: Text;
  clear_search_label: string;
  skin_tone_generic_label: string;
  skin_tone_light_label: string;
  skin_tone_medium_light_label: string;
  skin_tone_medium_label: string;
  skin_tone_medium_dark_label: string;
  skin_tone_dark_label: string;

  constructor(data: RawNode) {
    super();
    this.id = data.id;
    this.categories = Parser.parseArray(data.categories);
    this.category_buttons = Parser.parseArray(data.categoryButtons);
    this.search_placeholder = new Text(data.searchPlaceholderText);
    this.search_no_results = new Text(data.searchNoResultsText);
    this.pick_skin_tone = new Text(data.pickSkinToneText);
    this.clear_search_label = data.clearSearchLabel;
    this.skin_tone_generic_label = data.skinToneGenericLabel;
    this.skin_tone_light_label = data.skinToneLightLabel;
    this.skin_tone_medium_light_label = data.skinToneMediumLightLabel;
    this.skin_tone_medium_label = data.skinToneMediumLabel;
    this.skin_tone_medium_dark_label = data.skinToneMediumDarkLabel;
    this.skin_tone_dark_label = data.skinToneDarkLabel;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/comments/PdgCommentChip.ts:

import Text from '../misc/Text.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class PdgCommentChip extends YTNode {
  static type = 'PdgCommentChip';

  text: Text;
  color_pallette: {
    background_color?: string;
    foreground_title_color?: string;
  };
  icon_type?: string;

  constructor(data: RawNode) {
    super();
    this.text = new Text(data.chipText);
    this.color_pallette = {
      background_color: data.chipColorPalette?.backgroundColor,
      foreground_title_color: data.chipColorPalette?.foregroundTitleColor
    };

    if (Reflect.has(data, 'chipIcon') && Reflect.has(data.chipIcon, 'iconType')) {
      this.icon_type = data.chipIcon.iconType;
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/comments/SponsorCommentBadge.ts:

import Thumbnail from '../misc/Thumbnail.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class SponsorCommentBadge extends YTNode {
  static type = 'SponsorCommentBadge';

  custom_badge: Thumbnail[];
  tooltip: string;

  constructor(data: RawNode) {
    super();
    this.custom_badge = Thumbnail.fromResponse(data.customBadge);
    this.tooltip = data.tooltip;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/AddBannerToLiveChatCommand.ts:

import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';
import { Parser } from '../../index.js';
import LiveChatBanner from './items/LiveChatBanner.js';

export default class AddBannerToLiveChatCommand extends YTNode {
  static type = 'AddBannerToLiveChatCommand';

  banner: LiveChatBanner | null;

  constructor(data: RawNode) {
    super();
    this.banner = Parser.parseItem(data.bannerRenderer, LiveChatBanner);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/AddChatItemAction.ts:

import { Parser } from '../../index.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class AddChatItemAction extends YTNode {
  static type = 'AddChatItemAction';

  item: YTNode;
  client_id?: string;

  constructor(data: RawNode) {
    super();
    this.item = Parser.parseItem(data.item);
    if (Reflect.has(data, 'clientId')) {
      this.client_id = data.clientId;
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/AddLiveChatTickerItemAction.ts:

import { Parser } from '../../index.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class AddLiveChatTickerItemAction extends YTNode {
  static type = 'AddLiveChatTickerItemAction';

  item: YTNode;
  duration_sec: string; // TODO: check this assumption.

  constructor(data: RawNode) {
    super();
    this.item = Parser.parseItem(data.item);
    this.duration_sec = data.durationSec;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/DimChatItemAction.ts:

import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class DimChatItemAction extends YTNode {
  static type = 'DimChatItemAction';

  client_assigned_id: string;

  constructor(data: RawNode) {
    super();
    this.client_assigned_id = data.clientAssignedId;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/LiveChatActionPanel.ts:

import { Parser } from '../../index.js';
import { type SuperParsedResult, YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class LiveChatActionPanel extends YTNode {
  static type = 'LiveChatActionPanel';

  id: string;
  contents: SuperParsedResult<YTNode>;
  target_id: string;

  constructor(data: RawNode) {
    super();
    this.id = data.id;
    this.contents = Parser.parse(data.contents);
    this.target_id = data.targetId;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/MarkChatItemAsDeletedAction.ts:

import Text from '../misc/Text.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class MarkChatItemAsDeletedAction extends YTNode {
  static type = 'MarkChatItemAsDeletedAction';

  deleted_state_message: Text;
  target_item_id: string;

  constructor(data: RawNode) {
    super();
    this.deleted_state_message = new Text(data.deletedStateMessage);
    this.target_item_id = data.targetItemId;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/MarkChatItemsByAuthorAsDeletedAction.ts:

import Text from '../misc/Text.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class MarkChatItemsByAuthorAsDeletedAction extends YTNode {
  static type = 'MarkChatItemsByAuthorAsDeletedAction';

  deleted_state_message: Text;
  external_channel_id: string;

  constructor(data: RawNode) {
    super();
    this.deleted_state_message = new Text(data.deletedStateMessage);
    this.external_channel_id = data.externalChannelId;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/RemoveBannerForLiveChatCommand.ts:

import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class RemoveBannerForLiveChatCommand extends YTNode {
  static type = 'RemoveBannerForLiveChatCommand';

  target_action_id: string;

  constructor(data: RawNode) {
    super();
    this.target_action_id = data.targetActionId;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/RemoveChatItemAction.ts:

import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class RemoveChatItemAction extends YTNode {
  static type = 'RemoveChatItemAction';

  target_item_id: string;

  constructor(data: RawNode) {
    super();
    this.target_item_id = data.targetItemId;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/RemoveChatItemByAuthorAction.ts:

import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class RemoveChatItemByAuthorAction extends YTNode {
  static type = 'RemoveChatItemByAuthorAction';

  external_channel_id: string;

  constructor(data: RawNode) {
    super();
    this.external_channel_id = data.externalChannelId;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/ReplaceChatItemAction.ts:

import { Parser } from '../../index.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class ReplaceChatItemAction extends YTNode {
  static type = 'ReplaceChatItemAction';

  target_item_id: string;
  replacement_item: YTNode;

  constructor(data: RawNode) {
    super();
    this.target_item_id = data.targetItemId;
    this.replacement_item = Parser.parseItem(data.replacementItem);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/ReplayChatItemAction.ts:

import { Parser } from '../../index.js';
import { type ObservedArray, YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class ReplayChatItemAction extends YTNode {
  static type = 'ReplayChatItemAction';

  actions: ObservedArray<YTNode>;
  video_offset_time_msec: string;

  constructor(data: RawNode) {
    super();
    this.actions = Parser.parseArray(data.actions?.map((action: RawNode) => {
      delete action.clickTrackingParams;
      return action;
    }));

    this.video_offset_time_msec = data.videoOffsetTimeMsec;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/ShowLiveChatActionPanelAction.ts:

import { Parser } from '../../index.js';
import LiveChatActionPanel from './LiveChatActionPanel.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class ShowLiveChatActionPanelAction extends YTNode {
  static type = 'ShowLiveChatActionPanelAction';

  panel_to_show: LiveChatActionPanel | null;

  constructor(data: RawNode) {
    super();
    this.panel_to_show = Parser.parseItem(data.panelToShow, LiveChatActionPanel);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/ShowLiveChatDialogAction.ts:

import { Parser } from '../../index.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class ShowLiveChatDialogAction extends YTNode {
  static type = 'ShowLiveChatDialogAction';

  dialog: YTNode;

  constructor(data: RawNode) {
    super();
    this.dialog = Parser.parseItem(data.dialog);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/ShowLiveChatTooltipCommand.ts:

import { Parser } from '../../index.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class ShowLiveChatTooltipCommand extends YTNode {
  static type = 'ShowLiveChatTooltipCommand';

  tooltip: YTNode;

  constructor(data: RawNode) {
    super();
    this.tooltip = Parser.parseItem(data.tooltip);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/UpdateDateTextAction.ts:

import Text from '../misc/Text.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class UpdateDateTextAction extends YTNode {
  static type = 'UpdateDateTextAction';

  date_text: string;

  constructor(data: RawNode) {
    super();
    this.date_text = new Text(data.dateText).toString();
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/UpdateDescriptionAction.ts:

import Text from '../misc/Text.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class UpdateDescriptionAction extends YTNode {
  static type = 'UpdateDescriptionAction';

  description: Text;

  constructor(data: RawNode) {
    super();
    this.description = new Text(data.description);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/UpdateLiveChatPollAction.ts:

import { Parser } from '../../index.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class UpdateLiveChatPollAction extends YTNode {
  static type = 'UpdateLiveChatPollAction';

  poll_to_update: YTNode;

  constructor(data: RawNode) {
    super();
    this.poll_to_update = Parser.parseItem(data.pollToUpdate);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/UpdateTitleAction.ts:

import Text from '../misc/Text.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class UpdateTitleAction extends YTNode {
  static type = 'UpdateTitleAction';

  title: Text;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/UpdateToggleButtonTextAction.ts:

import Text from '../misc/Text.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class UpdateToggleButtonTextAction extends YTNode {
  static type = 'UpdateToggleButtonTextAction';

  default_text: string;
  toggled_text: string;
  button_id: string;

  constructor(data: RawNode) {
    super();
    this.default_text = new Text(data.defaultText).toString();
    this.toggled_text = new Text(data.toggledText).toString();
    this.button_id = data.buttonId;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/UpdateViewershipAction.ts:

import Text from '../misc/Text.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class UpdateViewershipAction extends YTNode {
  static type = 'UpdateViewershipAction';

  view_count: Text;
  extra_short_view_count: Text;
  original_view_count: Number;
  unlabeled_view_count_value: Text;
  is_live: boolean;

  constructor(data: RawNode) {
    super();
    const view_count_renderer = data.viewCount.videoViewCountRenderer;
    this.view_count = new Text(view_count_renderer.viewCount);
    this.extra_short_view_count = new Text(view_count_renderer.extraShortViewCount);
    this.original_view_count = parseInt(view_count_renderer.originalViewCount);
    this.unlabeled_view_count_value = new Text(view_count_renderer.unlabeledViewCountValue);
    this.is_live = view_count_renderer.isLive;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/items/LiveChatAutoModMessage.ts:

import { Parser } from '../../../index.js';
import Button from '../../Button.js';
import NavigationEndpoint from '../../NavigationEndpoint.js';
import Text from '../../misc/Text.js';

import { YTNode, type ObservedArray } from '../../../helpers.js';
import type { RawNode } from '../../../index.js';

export default class LiveChatAutoModMessage extends YTNode {
  static type = 'LiveChatAutoModMessage';

  menu_endpoint?: NavigationEndpoint;
  moderation_buttons: ObservedArray<Button>;
  auto_moderated_item: YTNode;
  header_text: Text;
  timestamp: number;
  id: string;

  constructor(data: RawNode) {
    super();
    this.menu_endpoint = new NavigationEndpoint(data.contextMenuEndpoint);
    this.moderation_buttons = Parser.parseArray(data.moderationButtons, Button);
    this.auto_moderated_item = Parser.parseItem(data.autoModeratedItem);
    this.header_text = new Text(data.headerText);
    this.timestamp = Math.floor(parseInt(data.timestampUsec) / 1000);
    this.id = data.id;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/items/LiveChatBanner.ts:

import { YTNode } from '../../../helpers.js';
import type { RawNode } from '../../../index.js';
import { Parser } from '../../../index.js';
import LiveChatBannerHeader from './LiveChatBannerHeader.js';

export default class LiveChatBanner extends YTNode {
  static type = 'LiveChatBanner';

  header: LiveChatBannerHeader | null;
  contents: YTNode;
  action_id: string;
  viewer_is_creator: boolean;
  target_id: string;
  is_stackable: boolean;
  background_type: string;

  constructor(data: RawNode) {
    super();
    this.header = Parser.parseItem(data.header, LiveChatBannerHeader);
    this.contents = Parser.parseItem(data.contents);
    this.action_id = data.actionId;
    this.viewer_is_creator = data.viewerIsCreator;
    this.target_id = data.targetId;
    this.is_stackable = data.isStackable;
    this.background_type = data.backgroundType;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/items/LiveChatBannerHeader.ts:

import { YTNode } from '../../../helpers.js';
import type { RawNode } from '../../../index.js';
import { Parser } from '../../../index.js';
import Button from '../../Button.js';
import Text from '../../misc/Text.js';

export default class LiveChatBannerHeader extends YTNode {
  static type = 'LiveChatBannerHeader';

  text: Text;
  icon_type?: string;
  context_menu_button: Button | null;

  constructor(data: RawNode) {
    super();
    this.text = new Text(data.text);

    if (Reflect.has(data, 'icon') && Reflect.has(data.icon, 'iconType')) {
      this.icon_type = data.icon.iconType;
    }

    this.context_menu_button = Parser.parseItem(data.contextMenuButton, Button);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/items/LiveChatBannerPoll.ts:

import { YTNode } from '../../../helpers.js';
import type { RawNode } from '../../../index.js';
import { Parser } from '../../../index.js';
import Button from '../../Button.js';
import Text from '../../misc/Text.js';
import Thumbnail from '../../misc/Thumbnail.js';

export default class LiveChatBannerPoll extends YTNode {
  static type = 'LiveChatBannerPoll';

  poll_question: Text;
  author_photo: Thumbnail[];

  choices: {
    option_id: string;
    text: string;
  }[];

  collapsed_state_entity_key: string;
  live_chat_poll_state_entity_key: string;
  context_menu_button: Button | null;

  constructor(data: RawNode) {
    super();
    this.poll_question = new Text(data.pollQuestion);
    this.author_photo = Thumbnail.fromResponse(data.authorPhoto);

    this.choices = data.pollChoices.map((choice: RawNode) => ({
      option_id: choice.pollOptionId,
      text: new Text(choice.text).toString() // XXX: This toString should probably not be used here.
    }));

    this.collapsed_state_entity_key = data.collapsedStateEntityKey;
    this.live_chat_poll_state_entity_key = data.liveChatPollStateEntityKey;
    this.context_menu_button = Parser.parseItem(data.contextMenuButton, Button);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/items/LiveChatMembershipItem.ts:

import { YTNode } from '../../../helpers.js';
import type { RawNode } from '../../../index.js';
import NavigationEndpoint from '../../NavigationEndpoint.js';
import Author from '../../misc/Author.js';
import Text from '../../misc/Text.js';

export default class LiveChatMembershipItem extends YTNode {
  static type = 'LiveChatMembershipItem';

  id: string;
  timestamp: number;
  header_subtext: Text;
  author: Author;
  menu_endpoint: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.id = data.id;
    this.timestamp = Math.floor(parseInt(data.timestampUsec) / 1000);
    this.header_subtext = new Text(data.headerSubtext);
    this.author = new Author(data.authorName, data.authorBadges, data.authorPhoto, data.authorExternalChannelId);
    this.menu_endpoint = new NavigationEndpoint(data.contextMenuEndpoint);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/items/LiveChatPaidMessage.ts:

import { YTNode } from '../../../helpers.js';
import type { RawNode } from '../../../index.js';
import NavigationEndpoint from '../../NavigationEndpoint.js';
import Author from '../../misc/Author.js';
import Text from '../../misc/Text.js';

export default class LiveChatPaidMessage extends YTNode {
  static type = 'LiveChatPaidMessage';

  message: Text;
  author: Author;
  header_background_color: number;
  header_text_color: number;
  body_background_color: number;
  body_text_color: number;
  purchase_amount: string;
  menu_endpoint: NavigationEndpoint;
  timestamp: number;
  timestamp_text: string;
  id: string;

  constructor(data: RawNode) {
    super();
    this.message = new Text(data.message);

    this.author = new Author(
      data.authorName,
      data.authorBadges,
      data.authorPhoto,
      data.authorExternalChannelId
    );

    this.header_background_color = data.headerBackgroundColor;
    this.header_text_color = data.headerTextColor;
    this.body_background_color = data.bodyBackgroundColor;
    this.body_text_color = data.bodyTextColor;
    this.purchase_amount = new Text(data.purchaseAmountText).toString();
    this.menu_endpoint = new NavigationEndpoint(data.contextMenuEndpoint);
    this.timestamp = Math.floor(parseInt(data.timestampUsec) / 1000);
    this.timestamp_text = new Text(data.timestampText).toString();
    this.id = data.id;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/items/LiveChatPaidSticker.ts:

import { YTNode } from '../../../helpers.js';
import type { RawNode } from '../../../index.js';
import NavigationEndpoint from '../../NavigationEndpoint.js';
import Author from '../../misc/Author.js';
import Text from '../../misc/Text.js';
import Thumbnail from '../../misc/Thumbnail.js';

export default class LiveChatPaidSticker extends YTNode {
  static type = 'LiveChatPaidSticker';

  id: string;
  author: Author;
  money_chip_background_color: number;
  money_chip_text_color: number;
  background_color: number;
  author_name_text_color: number;
  sticker: Thumbnail[];
  purchase_amount: string;
  context_menu: NavigationEndpoint;
  menu_endpoint: NavigationEndpoint;
  timestamp: number;

  constructor(data: RawNode) {
    super();
    this.id = data.id;

    this.author = new Author(
      data.authorName,
      data.authorBadges,
      data.authorPhoto,
      data.authorExternalChannelId
    );

    this.money_chip_background_color = data.moneyChipBackgroundColor;
    this.money_chip_text_color = data.moneyChipTextColor;
    this.background_color = data.backgroundColor;
    this.author_name_text_color = data.authorNameTextColor;
    this.sticker = Thumbnail.fromResponse(data.sticker);
    this.purchase_amount = new Text(data.purchaseAmountText).toString();
    this.menu_endpoint = new NavigationEndpoint(data.contextMenuEndpoint);
    this.context_menu = this.menu_endpoint;
    this.timestamp = Math.floor(parseInt(data.timestampUsec) / 1000);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/items/LiveChatPlaceholderItem.ts:

import { YTNode } from '../../../helpers.js';
import type { RawNode } from '../../../index.js';

export default class LiveChatPlaceholderItem extends YTNode {
  static type = 'LiveChatPlaceholderItem';

  id: string;
  timestamp: number;

  constructor(data: RawNode) {
    super();
    this.id = data.id;
    this.timestamp = Math.floor(parseInt(data.timestampUsec) / 1000);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/items/LiveChatProductItem.ts:

import { Parser } from '../../../index.js';
import { YTNode } from '../../../helpers.js';
import type { RawNode } from '../../../index.js';

import Text from '../../misc/Text.js';
import Thumbnail from '../../misc/Thumbnail.js';
import NavigationEndpoint from '../../NavigationEndpoint.js';

export default class LiveChatProductItem extends YTNode {
  static type = 'LiveChatProductItem';

  title: string;
  accessibility_title: string;
  thumbnail: Thumbnail[];
  price: string;
  vendor_name: string;
  from_vendor_text: string;
  information_button: YTNode;
  endpoint: NavigationEndpoint;
  creator_message: string;
  creator_name: string;
  author_photo: Thumbnail[];
  information_dialog: YTNode;
  is_verified: boolean;
  creator_custom_message: Text;

  constructor(data: RawNode) {
    super();
    this.title = data.title;
    this.accessibility_title = data.accessibilityTitle;
    this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
    this.price = data.price;
    this.vendor_name = data.vendorName;
    this.from_vendor_text = data.fromVendorText;
    this.information_button = Parser.parseItem(data.informationButton);
    this.endpoint = new NavigationEndpoint(data.onClickCommand);
    this.creator_message = data.creatorMessage;
    this.creator_name = data.creatorName;
    this.author_photo = Thumbnail.fromResponse(data.authorPhoto);
    this.information_dialog = Parser.parseItem(data.informationDialog);
    this.is_verified = data.isVerified;
    this.creator_custom_message = new Text(data.creatorCustomMessage);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/items/LiveChatRestrictedParticipation.ts:

import { YTNode } from '../../../helpers.js';
import Text from '../../misc/Text.js';
import type { RawNode } from '../../../index.js';

export default class LiveChatRestrictedParticipation extends YTNode {
  static type = 'LiveChatRestrictedParticipation';

  message: Text;
  icon_type?: string;

  constructor(data: RawNode) {
    super();
    this.message = new Text(data.message);
    if (Reflect.has(data, 'icon') && Reflect.has(data.icon, 'iconType')) {
      this.icon_type = data.icon.iconType;
    }
    // TODO: parse onClickCommand
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/items/LiveChatTextMessage.ts:

import { type ObservedArray, YTNode } from '../../../helpers.js';
import type { RawNode } from '../../../index.js';
import { Parser } from '../../../index.js';
import Button from '../../Button.js';
import NavigationEndpoint from '../../NavigationEndpoint.js';
import Author from '../../misc/Author.js';
import Text from '../../misc/Text.js';

export class LiveChatMessageBase extends YTNode {
  static type = 'LiveChatMessageBase';

  message: Text;
  inline_action_buttons: ObservedArray<Button>;
  timestamp: number;
  id: string;

  constructor(data: RawNode) {
    super();
    this.message = new Text(data.message);
    this.inline_action_buttons = Parser.parseArray(data.inlineActionButtons, Button);
    this.timestamp = Math.floor(parseInt(data.timestampUsec) / 1000);
    this.id = data.id;
  }
}

export default class LiveChatTextMessage extends LiveChatMessageBase {
  static type = 'LiveChatTextMessage';

  author: Author;
  menu_endpoint: NavigationEndpoint;

  constructor(data: RawNode) {
    super(data);

    this.author = new Author(
      data.authorName,
      data.authorBadges,
      data.authorPhoto,
      data.authorExternalChannelId
    );

    this.menu_endpoint = new NavigationEndpoint(data.contextMenuEndpoint);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/items/LiveChatTickerPaidMessageItem.ts:

import Author from '../../misc/Author.js';
import { Parser } from '../../../index.js';
import NavigationEndpoint from '../../NavigationEndpoint.js';
import Text from '../../misc/Text.js';

import { YTNode } from '../../../helpers.js';
import type { RawNode } from '../../../index.js';

export default class LiveChatTickerPaidMessageItem extends YTNode {
  static type = 'LiveChatTickerPaidMessageItem';

  author: Author;
  amount: Text;
  duration_sec: string;
  full_duration_sec: string;
  show_item: YTNode;
  show_item_endpoint: NavigationEndpoint;
  id: string;

  constructor(data: RawNode) {
    super();
    this.author = new Author(
      data.authorName,
      data.authorBadges,
      data.authorPhoto,
      data.authorExternalChannelId
    );

    this.amount = new Text(data.amount);
    this.duration_sec = data.durationSec;
    this.full_duration_sec = data.fullDurationSec;
    this.show_item = Parser.parseItem(data.showItemEndpoint?.showLiveChatItemEndpoint?.renderer);
    this.show_item_endpoint = new NavigationEndpoint(data.showItemEndpoint);
    this.id = data.id;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/items/LiveChatTickerPaidStickerItem.ts:

import LiveChatTickerPaidMessageItem from './LiveChatTickerPaidMessageItem.js';

export default class LiveChatTickerPaidStickerItem extends LiveChatTickerPaidMessageItem {
  static type = 'LiveChatTickerPaidStickerItem';
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/items/LiveChatTickerSponsorItem.ts:

import { YTNode } from '../../../helpers.js';
import type { RawNode } from '../../../index.js';
import Author from '../../misc/Author.js';
import Text from '../../misc/Text.js';

export default class LiveChatTickerSponsorItem extends YTNode {
  static type = 'LiveChatTickerSponsorItem';

  id: string;
  detail: Text;
  author: Author;
  duration_sec: string;

  constructor(data: RawNode) {
    super();
    this.id = data.id;
    this.detail = new Text(data.detailText);
    this.author = new Author(
      data.authorName,
      data.authorBadges,
      data.sponsorPhoto,
      data.authorExternalChannelId
    );
    this.duration_sec = data.durationSec;
    // TODO: Parse remaining props.
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/items/LiveChatViewerEngagementMessage.ts:

import { Parser } from '../../../index.js';
import { LiveChatMessageBase } from './LiveChatTextMessage.js';
import type { RawNode } from '../../../index.js';
import type { YTNode } from '../../../helpers.js';

export default class LiveChatViewerEngagementMessage extends LiveChatMessageBase {
  static type = 'LiveChatViewerEngagementMessage';

  icon_type?: string;
  action_button: YTNode;

  constructor(data: RawNode) {
    super(data);
    if (Reflect.has(data, 'icon') && Reflect.has(data.icon, 'iconType')) {
      this.icon_type = data.icon.iconType;
    }
    this.action_button = Parser.parseItem(data.actionButton);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/livechat/items/PollHeader.ts:

import { YTNode } from '../../../helpers.js';
import type { RawNode } from '../../../index.js';
import { Parser } from '../../../index.js';
import Button from '../../Button.js';
import Text from '../../misc/Text.js';
import Thumbnail from '../../misc/Thumbnail.js';

export default class PollHeader extends YTNode {
  static type = 'PollHeader';

  poll_question: Text;
  thumbnails: Thumbnail[];
  metadata: Text;
  live_chat_poll_type: string;
  context_menu_button: Button | null;

  constructor(data: RawNode) {
    super();
    this.poll_question = new Text(data.pollQuestion);
    this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
    this.metadata = new Text(data.metadataText);
    this.live_chat_poll_type = data.liveChatPollType;
    this.context_menu_button = Parser.parseItem(data.contextMenuButton, Button);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/menus/Menu.ts:

import { Parser } from '../../index.js';
import type { ObservedArray } from '../../helpers.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class Menu extends YTNode {
  static type = 'Menu';

  items: ObservedArray<YTNode>;
  top_level_buttons: ObservedArray<YTNode>;
  label?: string;

  constructor(data: RawNode) {
    super();
    this.items = Parser.parseArray(data.items);
    this.top_level_buttons = Parser.parseArray(data.topLevelButtons);

    if (Reflect.has(data, 'accessibility') && Reflect.has(data.accessibility, 'accessibilityData')) {
      this.label = data.accessibility.accessibilityData.label;
    }
  }

  // XXX: alias for consistency
  get contents() {
    return this.items;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/menus/MenuNavigationItem.ts:

import Button from '../Button.js';
import type { RawNode } from '../../index.js';

export default class MenuNavigationItem extends Button {
  static type = 'MenuNavigationItem';

  constructor(data: RawNode) {
    super(data);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/menus/MenuPopup.ts:

import type { ObservedArray } from '../../helpers.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';
import { Parser } from '../../index.js';
import MenuNavigationItem from './MenuNavigationItem.js';
import MenuServiceItem from './MenuServiceItem.js';

export default class MenuPopup extends YTNode {
  static type = 'MenuPopup';

  items: ObservedArray<MenuNavigationItem | MenuServiceItem>;

  constructor(data: RawNode) {
    super();
    this.items = Parser.parseArray(data.items, [ MenuNavigationItem, MenuServiceItem ]);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/menus/MenuServiceItem.ts:

import Button from '../Button.js';
import type { RawNode } from '../../index.js';

export default class MenuServiceItem extends Button {
  static type = 'MenuServiceItem';

  constructor(data: RawNode) {
    super(data);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/menus/MenuServiceItemDownload.ts:

import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';
import NavigationEndpoint from '../NavigationEndpoint.js';

export default class MenuServiceItemDownload extends YTNode {
  static type = 'MenuServiceItemDownload';

  has_separator: boolean;
  endpoint: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.has_separator = !!data.hasSeparator;
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint || data.serviceEndpoint);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/menus/MultiPageMenu.ts:

import type { ObservedArray } from '../../helpers.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';
import { Parser } from '../../index.js';

export default class MultiPageMenu extends YTNode {
  static type = 'MultiPageMenu';

  header: YTNode;
  sections: ObservedArray<YTNode>;
  style: string;

  constructor(data: RawNode) {
    super();
    this.header = Parser.parseItem(data.header);
    this.sections = Parser.parseArray(data.sections);
    this.style = data.style;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/menus/MultiPageMenuNotificationSection.ts:

import { Parser } from '../../index.js';
import { type SuperParsedResult, YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class MultiPageMenuNotificationSection extends YTNode {
  static type = 'MultiPageMenuNotificationSection';

  items: SuperParsedResult<YTNode>;

  constructor(data: RawNode) {
    super();
    this.items = Parser.parse(data.items);
  }

  // XXX: Alias for consistency.
  get contents() {
    return this.items;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/menus/MusicMenuItemDivider.ts:

import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class MusicMenuItemDivider extends YTNode {
  static type = 'MusicMenuItemDivider';

  // eslint-disable-next-line
  constructor(_data: RawNode) {
    super();
    // XXX: Should check if this ever has any data.
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/menus/MusicMultiSelectMenu.ts:

import type { ObservedArray } from '../../helpers.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';
import { Parser } from '../../index.js';
import Text from '../misc/Text.js';
import MusicMenuItemDivider from './MusicMenuItemDivider.js';
import MusicMultiSelectMenuItem from './MusicMultiSelectMenuItem.js';

export default class MusicMultiSelectMenu extends YTNode {
  static type = 'MusicMultiSelectMenu';

  title?: Text;
  options: ObservedArray<MusicMultiSelectMenuItem | MusicMenuItemDivider>;

  constructor(data: RawNode) {
    super();
    if (Reflect.has(data, 'title') && Reflect.has(data.title, 'musicMenuTitleRenderer')) {
      this.title = new Text(data.title.musicMenuTitleRenderer?.primaryText);
    }

    this.options = Parser.parseArray(data.options, [ MusicMultiSelectMenuItem, MusicMenuItemDivider ]);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/menus/MusicMultiSelectMenuItem.ts:

import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';
import NavigationEndpoint from '../NavigationEndpoint.js';
import Text from '../misc/Text.js';

export default class MusicMultiSelectMenuItem extends YTNode {
  static type = 'MusicMultiSelectMenuItem';

  title: string;
  form_item_entity_key: string;
  selected_icon_type?: string;
  endpoint?: NavigationEndpoint;
  selected: boolean;

  constructor(data: RawNode) {
    super();

    this.title = new Text(data.title).toString();
    this.form_item_entity_key = data.formItemEntityKey;

    if (Reflect.has(data, 'selectedIcon')) {
      this.selected_icon_type = data.selectedIcon.iconType;
    }

    // @TODO: Check if there any other endpoints we can parse.
    if (Reflect.has(data, 'selectedCommand')) {
      this.endpoint = new NavigationEndpoint(data.selectedCommand);
    }

    this.selected = !!this.endpoint;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/menus/SimpleMenuHeader.ts:

import type { ObservedArray } from '../../helpers.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';
import { Parser } from '../../index.js';
import Button from '../Button.js';
import Text from '../misc/Text.js';

export default class SimpleMenuHeader extends YTNode {
  static type = 'SimpleMenuHeader';

  title: Text;
  buttons: ObservedArray<Button>;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.buttons = Parser.parseArray(data.buttons, Button);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/misc/Author.ts:

import * as Constants from '../../../utils/Constants.js';
import type { YTNode } from '../../helpers.js';
import { observe, type ObservedArray } from '../../helpers.js';
import { Parser, type RawNode } from '../../index.js';
import type NavigationEndpoint from '../NavigationEndpoint.js';
import Text from './Text.js';
import type TextRun from './TextRun.js';
import Thumbnail from './Thumbnail.js';

export default class Author {
  id: string;
  name: string;
  thumbnails: Thumbnail[];
  endpoint?: NavigationEndpoint;
  badges: ObservedArray<YTNode>;
  is_moderator?: boolean;
  is_verified?: boolean;
  is_verified_artist?: boolean;
  url: string;

  constructor(item: RawNode, badges?: any, thumbs?: any, id?: string) {
    const nav_text = new Text(item);

    this.id = id || (nav_text?.runs?.[0] as TextRun)?.endpoint?.payload?.browseId || nav_text?.endpoint?.payload?.browseId || 'N/A';
    this.name = nav_text?.text || 'N/A';
    this.thumbnails = thumbs ? Thumbnail.fromResponse(thumbs) : [];
    this.endpoint = ((nav_text?.runs?.[0] as TextRun) as TextRun)?.endpoint || nav_text?.endpoint;

    if (badges) {
      if (Array.isArray(badges)) {
        this.badges = Parser.parseArray(badges);
        this.is_moderator = this.badges?.some((badge: any) => badge.icon_type == 'MODERATOR');
        this.is_verified = this.badges?.some((badge: any) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED');
        this.is_verified_artist = this.badges?.some((badge: any) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED_ARTIST');
      } else {
        this.badges = observe([] as YTNode[]);
        this.is_verified = !!badges.isVerified;
        this.is_verified_artist = !!badges.isArtist;
      }
    } else {
      this.badges = observe([] as YTNode[]);
    }

    // @TODO: Refactor this mess.
    this.url =
      (nav_text?.runs?.[0] as TextRun)?.endpoint?.metadata?.api_url === '/browse' &&
        `${Constants.URLS.YT_BASE}${(nav_text?.runs?.[0] as TextRun)?.endpoint?.payload?.canonicalBaseUrl || `/u/${(nav_text?.runs?.[0] as TextRun)?.endpoint?.payload?.browseId}`}` ||
        `${Constants.URLS.YT_BASE}${nav_text?.endpoint?.payload?.canonicalBaseUrl || `/u/${nav_text?.endpoint?.payload?.browseId}`}`;
  }

  get best_thumbnail(): Thumbnail | undefined {
    return this.thumbnails[0];
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/misc/ChildElement.ts:

import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class ChildElement extends YTNode {
  static type = 'ChildElement';

  text?: string;
  properties;
  child_elements?: ChildElement[];

  constructor(data: RawNode) {
    super();
    if (Reflect.has(data, 'type') && Reflect.has(data.type, 'textType')) {
      this.text = data.type.textType.text?.content;
    }

    this.properties = data.properties;

    if (Reflect.has(data, 'childElements')) {
      this.child_elements = data.childElements.map((el: RawNode) => new ChildElement(el));
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/misc/EmojiRun.ts:

import type { RawNode } from '../../index.js';
import { escape, type Run } from './Text.js';
import Thumbnail from './Thumbnail.js';

export default class EmojiRun implements Run {
  text: string;
  emoji: {
    emoji_id: string;
    shortcuts: string[];
    search_terms: string[];
    image: Thumbnail[];
    is_custom: boolean;
  };

  constructor(data: RawNode) {
    this.text =
      data.emoji?.emojiId ||
      data.emoji?.shortcuts?.[0] ||
      data.text ||
      '';

    this.emoji = {
      emoji_id: data.emoji.emojiId,
      shortcuts: data.emoji?.shortcuts || [],
      search_terms: data.emoji?.searchTerms || [],
      image: Thumbnail.fromResponse(data.emoji.image),
      is_custom: !!data.emoji?.isCustomEmoji
    };
  }

  toString(): string {
    return this.text;
  }

  toHTML(): string {
    const escaped_text = escape(this.text);
    return `<img src="${this.emoji.image[0].url}" alt="${escaped_text}" title="${escaped_text}" style="display: inline-block; vertical-align: text-top; height: var(--yt-emoji-size, 1rem); width: var(--yt-emoji-size, 1rem);" loading="lazy" crossorigin="anonymous" />`;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/misc/Format.ts:

import type Player from '../../../core/Player.js';
import { InnertubeError } from '../../../utils/Utils.js';
import type { RawNode } from '../../index.js';

export default class Format {
  #this_response_nsig_cache?: Map<string, string>;

  itag: number;
  url?: string;
  width?: number;
  height?: number;
  last_modified: Date;
  content_length?: number;
  quality?: string;
  xtags?: string;
  drm_families?: string[];
  fps?: number;
  quality_label?: string;
  projection_type?: 'RECTANGULAR' | 'EQUIRECTANGULAR' | 'EQUIRECTANGULAR_THREED_TOP_BOTTOM' | 'MESH';
  average_bitrate?: number;
  bitrate: number;
  spatial_audio_type?: 'AMBISONICS_5_1' | 'AMBISONICS_QUAD' | 'FOA_WITH_NON_DIEGETIC';
  target_duration_dec?: number;
  fair_play_key_uri?: string;
  stereo_layout?: 'LEFT_RIGHT' | 'TOP_BOTTOM';
  max_dvr_duration_sec?: number;
  high_replication?: boolean;
  audio_quality?: string;
  approx_duration_ms: number;
  audio_sample_rate?: number;
  audio_channels?: number;
  loudness_db?: number;
  signature_cipher?: string;
  is_drc?: boolean;
  drm_track_type?: string;
  distinct_params?: string;
  track_absolute_loudness_lkfs?: number;
  mime_type: string;
  is_type_otf: boolean;
  init_range?: {
    start: number;
    end: number;
  };
  index_range?: {
    start: number;
    end: number;
  };
  cipher?: string;
  audio_track?: {
    audio_is_default: boolean;
    display_name: string;
    id: string;
  };
  has_audio: boolean;
  has_video: boolean;
  has_text: boolean;
  language?: string | null;
  is_dubbed?: boolean;
  is_descriptive?: boolean;
  is_secondary?: boolean;
  is_original?: boolean;
  color_info?: {
    primaries?: string;
    transfer_characteristics?: string;
    matrix_coefficients?: string;
  };
  caption_track?: {
    display_name: string;
    vss_id: string;
    language_code: string;
    kind?: 'asr' | 'frc';
    id: string;
  };

  constructor(data: RawNode, this_response_nsig_cache?: Map<string, string>) {
    if (this_response_nsig_cache) {
      this.#this_response_nsig_cache = this_response_nsig_cache;
    }

    this.itag = data.itag;
    this.mime_type = data.mimeType;
    this.is_type_otf = data.type === 'FORMAT_STREAM_TYPE_OTF';
    this.bitrate = data.bitrate;
    this.average_bitrate = data.averageBitrate;

    if (Reflect.has(data, 'width') && Reflect.has(data, 'height')) {
      this.width = parseInt(data.width);
      this.height = parseInt(data.height);
    }

    if (Reflect.has(data, 'projectionType'))
      this.projection_type = data.projectionType;

    if (Reflect.has(data, 'stereoLayout'))
      this.stereo_layout = data.stereoLayout?.replace('STEREO_LAYOUT_', '');

    if (Reflect.has(data, 'initRange'))
      this.init_range = {
        start: parseInt(data.initRange.start),
        end: parseInt(data.initRange.end)
      };

    if (Reflect.has(data, 'indexRange'))
      this.index_range = {
        start: parseInt(data.indexRange.start),
        end: parseInt(data.indexRange.end)
      };

    this.last_modified = new Date(Math.floor(parseInt(data.lastModified) / 1000));

    if (Reflect.has(data, 'contentLength'))
      this.content_length = parseInt(data.contentLength);

    if (Reflect.has(data, 'quality'))
      this.quality = data.quality;

    if (Reflect.has(data, 'qualityLabel'))
      this.quality_label = data.qualityLabel;

    if (Reflect.has(data, 'fps'))
      this.fps = data.fps;

    if (Reflect.has(data, 'url'))
      this.url = data.url;

    if (Reflect.has(data, 'cipher'))
      this.cipher = data.cipher;

    if (Reflect.has(data, 'signatureCipher'))
      this.signature_cipher = data.signatureCipher;

    if (Reflect.has(data, 'audioQuality'))
      this.audio_quality = data.audioQuality;

    this.approx_duration_ms = parseInt(data.approxDurationMs);

    if (Reflect.has(data, 'audioSampleRate'))
      this.audio_sample_rate = parseInt(data.audioSampleRate);

    if (Reflect.has(data, 'audioChannels'))
      this.audio_channels = data.audioChannels;

    if (Reflect.has(data, 'loudnessDb'))
      this.loudness_db = data.loudnessDb;

    if (Reflect.has(data, 'spatialAudioType'))
      this.spatial_audio_type = data.spatialAudioType?.replace('SPATIAL_AUDIO_TYPE_', '');

    if (Reflect.has(data, 'maxDvrDurationSec'))
      this.max_dvr_duration_sec = data.maxDvrDurationSec;

    if (Reflect.has(data, 'targetDurationSec'))
      this.target_duration_dec = data.targetDurationSec;

    this.has_audio = !!data.audioBitrate || !!data.audioQuality;
    this.has_video = !!data.qualityLabel;
    this.has_text = !!data.captionTrack;

    if (Reflect.has(data, 'xtags'))
      this.xtags = data.xtags;

    if (Reflect.has(data, 'fairPlayKeyUri'))
      this.fair_play_key_uri = data.fairPlayKeyUri;

    if (Reflect.has(data, 'drmFamilies'))
      this.drm_families = data.drmFamilies;

    if (Reflect.has(data, 'drmTrackType'))
      this.drm_track_type = data.drmTrackType;

    if (Reflect.has(data, 'distinctParams'))
      this.distinct_params = data.distinctParams;

    if (Reflect.has(data, 'trackAbsoluteLoudnessLkfs'))
      this.track_absolute_loudness_lkfs = data.trackAbsoluteLoudnessLkfs;

    if (Reflect.has(data, 'highReplication'))
      this.high_replication = data.highReplication;

    if (Reflect.has(data, 'colorInfo'))
      this.color_info = {
        primaries: data.colorInfo.primaries?.replace('COLOR_PRIMARIES_', ''),
        transfer_characteristics: data.colorInfo.transferCharacteristics?.replace('COLOR_TRANSFER_CHARACTERISTICS_', ''),
        matrix_coefficients: data.colorInfo.matrixCoefficients?.replace('COLOR_MATRIX_COEFFICIENTS_', '')
      };

    if (Reflect.has(data, 'audioTrack'))
      this.audio_track = {
        audio_is_default: data.audioTrack.audioIsDefault,
        display_name: data.audioTrack.displayName,
        id: data.audioTrack.id
      };

    if (Reflect.has(data, 'captionTrack'))
      this.caption_track = {
        display_name: data.captionTrack.displayName,
        vss_id: data.captionTrack.vssId,
        language_code: data.captionTrack.languageCode,
        kind: data.captionTrack.kind,
        id: data.captionTrack.id
      };

    if (this.has_audio || this.has_text) {
      const args = new URLSearchParams(this.cipher || this.signature_cipher);
      const url_components = new URLSearchParams(args.get('url') || this.url);

      const xtags = url_components.get('xtags')?.split(':');

      this.language = xtags?.find((x: string) => x.startsWith('lang='))?.split('=')[1] || null;

      if (this.has_audio) {
        this.is_drc = !!data.isDrc || !!xtags?.includes('drc=1');

        const audio_content = xtags?.find((x) => x.startsWith('acont='))?.split('=')[1];
        this.is_dubbed = audio_content === 'dubbed';
        this.is_descriptive = audio_content === 'descriptive';
        this.is_secondary = audio_content === 'secondary';
        this.is_original = audio_content === 'original' || (!this.is_dubbed && !this.is_descriptive && !this.is_secondary && !this.is_drc);
      }

      // Some text tracks don't have xtags while others do
      if (this.has_text && !this.language && this.caption_track) {
        this.language = this.caption_track.language_code;
      }
    }
  }

  /**
   * Deciphers the streaming url of the format.
   * @returns Deciphered URL.
   */
  decipher(player: Player | undefined): string {
    if (!player) throw new InnertubeError('Cannot decipher format, this session appears to have no valid player.');
    return player.decipher(this.url, this.signature_cipher, this.cipher, this.#this_response_nsig_cache);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/misc/Text.ts:

import { Log } from '../../../utils/index.js';
import type { RawNode } from '../../index.js';
import NavigationEndpoint from '../NavigationEndpoint.js';
import EmojiRun from './EmojiRun.js';
import TextRun from './TextRun.js';

export interface Run {
  text: string;
  toString(): string;
  toHTML(): string;
}

export function escape(text: string) {
  return text
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;');
}

// Place this here, instead of in a private static property,
// To avoid the performance penalty of the private field polyfill
const TAG = 'Text';

export default class Text {
  text?: string;
  runs?: (EmojiRun | TextRun)[];
  endpoint?: NavigationEndpoint;

  constructor(data: RawNode) {
    if (typeof data === 'object' && data !== null && Reflect.has(data, 'runs') && Array.isArray(data.runs)) {
      this.runs = data.runs.map((run: RawNode) => run.emoji ?
        new EmojiRun(run) :
        new TextRun(run)
      );
      this.text = this.runs.map((run) => run.text).join('');
    } else {
      this.text = data?.simpleText;
    }
    if (typeof data === 'object' && data !== null && Reflect.has(data, 'navigationEndpoint')) {
      this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    }
    if (typeof data === 'object' && data !== null && Reflect.has(data, 'titleNavigationEndpoint')) {
      this.endpoint = new NavigationEndpoint(data.titleNavigationEndpoint);
    }
    if (!this.endpoint) {
      if ((this.runs?.[0] as TextRun)?.endpoint) {
        this.endpoint = (this.runs?.[0] as TextRun)?.endpoint;
      }
    }
  }

  static fromAttributed(data: AttributedText) {
    const {
      content,
      styleRuns: style_runs,
      commandRuns: command_runs,
      attachmentRuns: attachment_runs
    } = data;

    const runs: RawRun[] = [
      {
        text: content,
        startIndex: 0
      }
    ];

    if (style_runs || command_runs || attachment_runs) {
      if (style_runs) {
        for (const style_run of style_runs) {
          if (
            style_run.italic ||
            style_run.strikethrough === 'LINE_STYLE_SINGLE' ||
            style_run.weightLabel === 'FONT_WEIGHT_MEDIUM' ||
            style_run.weightLabel === 'FONT_WEIGHT_BOLD'
          ) {
            const matching_run = findMatchingRun(runs, style_run);

            if (!matching_run) {
              Log.warn(TAG, 'Unable to find matching run for style run. Skipping...', {
                style_run,
                input_data: data,
                // For performance reasons, web browser consoles only expand an object, when the user clicks on it,
                // So if we log the original runs object, it might have changed by the time the user looks at it.
                // Deep clone, so that we log the exact state of the runs at this point.
                parsed_runs: JSON.parse(JSON.stringify(runs))
              });

              continue;
            }

            // Comments use MEDIUM for bold text and video descriptions use BOLD for bold text
            insertSubRun(runs, matching_run, style_run, {
              bold: style_run.weightLabel === 'FONT_WEIGHT_MEDIUM' || style_run.weightLabel === 'FONT_WEIGHT_BOLD',
              italics: style_run.italic,
              strikethrough: style_run.strikethrough === 'LINE_STYLE_SINGLE'
            });
          } else {
            Log.debug(TAG, 'Skipping style run as it is doesn\'t have any information that we parse.', {
              style_run,
              input_data: data
            });
          }
        }
      }

      if (command_runs) {
        for (const command_run of command_runs) {
          if (command_run.onTap) {
            const matching_run = findMatchingRun(runs, command_run);

            if (!matching_run) {
              Log.warn(TAG, 'Unable to find matching run for command run. Skipping...', {
                command_run,
                input_data: data,
                // For performance reasons, web browser consoles only expand an object, when the user clicks on it,
                // So if we log the original runs object, it might have changed by the time the user looks at it.
                // Deep clone, so that we log the exact state of the runs at this point.
                parsed_runs: JSON.parse(JSON.stringify(runs))
              });

              continue;
            }

            insertSubRun(runs, matching_run, command_run, {
              navigationEndpoint: command_run.onTap
            });
          } else {
            Log.debug(TAG, 'Skipping command run as it is missing the "doTap" property.', {
              command_run,
              input_data: data
            });
          }
        }
      }

      if (attachment_runs) {
        for (const attachment_run of attachment_runs) {
          const matching_run = findMatchingRun(runs, attachment_run);

          if (!matching_run) {
            Log.warn(TAG, 'Unable to find matching run for attachment run. Skipping...', {
              attachment_run,
              input_data: data,
              // For performance reasons, web browser consoles only expand an object, when the user clicks on it,
              // So if we log the original runs object, it might have changed by the time the user looks at it.
              // Deep clone, so that we log the exact state of the runs at this point.
              parsed_runs: JSON.parse(JSON.stringify(runs))
            });

            continue;
          }

          if (attachment_run.length === 0) {
            matching_run.attachment = attachment_run;
          } else {
            const offset_start_index = attachment_run.startIndex - matching_run.startIndex;

            const text = matching_run.text.substring(offset_start_index, offset_start_index + attachment_run.length);

            const is_custom_emoji = (/^:[^:]+:$/).test(text);

            if (attachment_run.element?.type?.imageType?.image && (is_custom_emoji || (/^(?:\p{Emoji}|\u200d)+$/u).test(text))) {
              const emoji = {
                image: attachment_run.element.type.imageType.image,
                isCustomEmoji: is_custom_emoji,
                shortcuts: is_custom_emoji ? [ text ] : undefined
              };

              insertSubRun(runs, matching_run, attachment_run, { emoji });
            } else {
              insertSubRun(runs, matching_run, attachment_run, {
                attachment: attachment_run
              });
            }
          }
        }
      }
    }

    return new Text({ runs });
  }

  /**
   * Converts the text to HTML.
   * @returns The HTML.
   */
  toHTML(): string | undefined {
    return this.runs ? this.runs.map((run) => run.toHTML()).join('') : this.text;
  }

  /**
   * Checks if the text is empty.
   * @returns Whether the text is empty.
   */
  isEmpty(): boolean {
    return this.text === undefined;
  }

  /**
   * Converts the text to a string.
   * @returns The text.
   */
  toString(): string {
    return this.text || 'N/A';
  }
}

function findMatchingRun(runs: RawRun[], response_run: ResponseRun) {
  return runs.find((run) => {
    return run.startIndex <= response_run.startIndex &&
      response_run.startIndex + response_run.length <= run.startIndex + run.text.length;
  });
}

function insertSubRun(runs: RawRun[], original_run: RawRun, response_run: ResponseRun, properties_to_add: Omit<RawRun, 'text' | 'startIndex'>) {
  const replace_index = runs.indexOf(original_run);
  const replacement_runs = [];

  const offset_start_index = response_run.startIndex - original_run.startIndex;

  // Stuff before the run
  if (response_run.startIndex > original_run.startIndex) {
    replacement_runs.push({
      ...original_run,
      text: original_run.text.substring(0, offset_start_index)
    });
  }

  replacement_runs.push({
    ...original_run,
    text: original_run.text.substring(offset_start_index, offset_start_index + response_run.length),
    startIndex: response_run.startIndex,
    ...properties_to_add
  });

  // Stuff after the run
  if (response_run.startIndex + response_run.length < original_run.startIndex + original_run.text.length) {
    replacement_runs.push({
      ...original_run,
      text: original_run.text.substring(offset_start_index + response_run.length),
      startIndex: response_run.startIndex + response_run.length
    });
  }

  runs.splice(replace_index, 1, ...replacement_runs);
}

interface RawRun {
  text: string,
  bold?: boolean;
  italics?: boolean;
  strikethrough?: boolean;
  navigationEndpoint?: RawNode;
  attachment?: RawNode;
  emoji?: RawNode;
  startIndex: number;
}

export interface AttributedText {
  content: string;
  styleRuns?: StyleRun[];
  commandRuns?: CommandRun[];
  attachmentRuns?: AttachmentRun[];
  decorationRuns?: ResponseRun[];
}

interface ResponseRun {
  startIndex: number;
  length: number;
}

interface StyleRun extends ResponseRun {
  italic?: boolean;
  weightLabel?: string;
  strikethrough?: string;
  fontFamilyName?: string;
  styleRunExtensions?: {
    styleRunColorMapExtension?: {
      colorMap?: {
        key: string,
        value: number
      }[]
    }
  }
}

interface CommandRun extends ResponseRun {
  onTap?: RawNode;
}

interface AttachmentRun extends ResponseRun {
  alignment?: string;
  element?: {
    type?: {
      imageType?: {
        image: RawNode,
        playbackState?: string;
      }
    };
    properties?: RawNode
  };
}

LuanRT/YouTube.js/blob/main/src/parser/classes/misc/TextRun.ts:

import NavigationEndpoint from '../NavigationEndpoint.js';
import { escape, type Run } from './Text.js';
import type { RawNode } from '../../index.js';

export default class TextRun implements Run {
  text: string;
  endpoint?: NavigationEndpoint;
  bold: boolean;
  italics: boolean;
  strikethrough: boolean;
  attachment;

  constructor(data: RawNode) {
    this.text = data.text;
    this.bold = Boolean(data.bold);
    this.italics = Boolean(data.italics);
    this.strikethrough = Boolean(data.strikethrough);

    if (Reflect.has(data, 'navigationEndpoint')) {
      this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
    }

    this.attachment = data.attachment;
  }

  toString(): string {
    return this.text;
  }

  toHTML(): string {
    const tags: string[] = [];

    if (this.bold) tags.push('b');
    if (this.italics) tags.push('i');
    if (this.strikethrough) tags.push('s');

    const escaped_text = escape(this.text);
    const styled_text = tags.map((tag) => `<${tag}>`).join('') + escaped_text + tags.map((tag) => `</${tag}>`).join('');
    const wrapped_text = `<span style="white-space: pre-wrap;">${styled_text}</span>`;

    if (this.attachment) {
      if (this.attachment.element.type.imageType.image.sources.length) {
        const { url } = this.attachment.element.type.imageType.image.sources[0];
        if (this.endpoint) {
          const nav_url = this.endpoint.toURL();
          if (nav_url) return `<a href="${nav_url}" class="yt-ch-link" display: block; width: fit-content; font-size: small;><img src="${url}" style="vertical-align: middle; height: ${this.attachment.element.properties.layoutProperties.height.value}px; width: ${this.attachment.element.properties.layoutProperties.width.value}px;">${wrapped_text}</a>`;
        }
      }
    }

    if (this.endpoint) {
      const url = this.endpoint.toURL();
      if (url) return `<a href="${url}">${wrapped_text}</a>`;
    }

    return wrapped_text;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/misc/Thumbnail.ts:

import type { RawNode } from '../../index.js';

export default class Thumbnail {
  url: string;
  width: number;
  height: number;

  constructor(data: RawNode) {
    this.url = data.url;
    this.width = data.width;
    this.height = data.height;
  }

  /**
   * Get thumbnails from response object.
   */
  static fromResponse(data: any): Thumbnail[] {
    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 [];
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/misc/VideoDetails.ts:

import Thumbnail from './Thumbnail.js';
import type { RawNode } from '../../index.js';

export default class VideoDetails {
  id: string;
  channel_id: string;
  title: string;
  duration: number;
  keywords: string[];
  is_owner_viewing: boolean;
  short_description: string;
  thumbnail: Thumbnail[];
  allow_ratings: boolean;
  view_count: number;
  author: string;
  is_private: boolean;
  is_live: boolean;
  is_live_content: boolean;
  is_live_dvr_enabled: boolean;
  is_upcoming: boolean;
  is_crawlable: boolean;
  is_post_live_dvr: boolean;
  is_low_latency_live_stream: boolean;
  live_chunk_readahead?: number;

  constructor(data: RawNode) {
    this.id = data.videoId;
    this.channel_id = data.channelId;
    this.title = data.title;
    this.duration = parseInt(data.lengthSeconds);
    this.keywords = data.keywords;
    this.is_owner_viewing = !!data.isOwnerViewing;
    this.short_description = data.shortDescription;
    this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
    this.allow_ratings = !!data.allowRatings;
    this.view_count = parseInt(data.viewCount);
    this.author = data.author;
    this.is_private = !!data.isPrivate;
    this.is_live = !!data.isLive;
    this.is_live_content = !!data.isLiveContent;
    this.is_live_dvr_enabled = !!data.isLiveDvrEnabled;
    this.is_low_latency_live_stream = !!data.isLowLatencyLiveStream;
    this.is_upcoming = !!data.isUpcoming;
    this.is_post_live_dvr = !!data.isPostLiveDvr;
    this.is_crawlable = !!data.isCrawlable;
    this.live_chunk_readahead = data.liveChunkReadahead;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ytkids/AnchoredSection.ts:

import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';
import { Parser } from '../../index.js';
import NavigationEndpoint from '../NavigationEndpoint.js';
import SectionList from '../SectionList.js';

export default class AnchoredSection extends YTNode {
  static type = 'AnchoredSection';

  title: string;
  content: SectionList | null;
  endpoint: NavigationEndpoint;
  category_assets: {
    asset_key: string;
    background_color: string;
  };
  category_type: string;

  constructor(data: RawNode) {
    super();
    this.title = data.title;
    this.content = Parser.parseItem(data.content, SectionList);
    this.endpoint = new NavigationEndpoint(data.navigationEndpoint);

    this.category_assets = {
      asset_key: data.categoryAssets?.assetKey,
      background_color: data.categoryAssets?.backgroundColor
    };

    this.category_type = data.categoryType;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ytkids/KidsBlocklistPicker.ts:

import Text from '../misc/Text.js';
import { YTNode } from '../../helpers.js';
import Button from '../Button.js';
import { Parser, type RawNode } from '../../index.js';
import KidsBlocklistPickerItem from './KidsBlocklistPickerItem.js';

export default class KidsBlocklistPicker extends YTNode {
  static type = 'KidsBlocklistPicker';

  title: Text;
  child_rows: KidsBlocklistPickerItem[] | null;
  done_button: Button | null;
  successful_toast_action_message: Text;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.child_rows = Parser.parse(data.childRows, true, [ KidsBlocklistPickerItem ]);
    this.done_button = Parser.parseItem(data.doneButton, [ Button ]);
    this.successful_toast_action_message = new Text(data.successfulToastActionMessage);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ytkids/KidsBlocklistPickerItem.ts:

import Text from '../misc/Text.js';
import { YTNode } from '../../helpers.js';
import { Parser, type RawNode } from '../../index.js';
import ToggleButton from '../ToggleButton.js';
import Thumbnail from '../misc/Thumbnail.js';
import type Actions from '../../../core/Actions.js';
import { InnertubeError } from '../../../utils/Utils.js';
import { type ApiResponse } from '../../../core/Actions.js';

export default class KidsBlocklistPickerItem extends YTNode {
  static type = 'KidsBlocklistPickerItem';

  #actions?: Actions;

  child_display_name: Text;
  child_account_description: Text;
  avatar: Thumbnail[];
  block_button: ToggleButton | null;
  blocked_entity_key: string;

  constructor(data: RawNode) {
    super();
    this.child_display_name = new Text(data.childDisplayName);
    this.child_account_description = new Text(data.childAccountDescription);
    this.avatar = Thumbnail.fromResponse(data.avatar);
    this.block_button = Parser.parseItem(data.blockButton, [ ToggleButton ]);
    this.blocked_entity_key = data.blockedEntityKey;
  }

  async blockChannel(): Promise<ApiResponse> {
    if (!this.#actions)
      throw new InnertubeError('An active caller must be provide to perform this operation.');

    const button = this.block_button;

    if (!button)
      throw new InnertubeError('Block button was not found.', { child_display_name: this.child_display_name });

    if (button.is_toggled)
      throw new InnertubeError('This channel is already blocked.', { child_display_name: this.child_display_name });

    const response = await button.endpoint.call(this.#actions, { parse: false });
    return response;
  }

  setActions(actions: Actions | undefined) {
    this.#actions = actions;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ytkids/KidsCategoriesHeader.ts:

import { Parser } from '../../index.js';
import Button from '../Button.js';
import KidsCategoryTab from './KidsCategoryTab.js';
import { type ObservedArray, YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class KidsCategoriesHeader extends YTNode {
  static type = 'kidsCategoriesHeader';

  category_tabs: ObservedArray<KidsCategoryTab>;
  privacy_button: Button | null;

  constructor(data: RawNode) {
    super();
    this.category_tabs = Parser.parseArray(data.categoryTabs, KidsCategoryTab);
    this.privacy_button = Parser.parseItem(data.privacyButtonRenderer, Button);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ytkids/KidsCategoryTab.ts:

import Text from '../misc/Text.js';
import NavigationEndpoint from '../NavigationEndpoint.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class KidsCategoryTab extends YTNode {
  static type = 'KidsCategoryTab';

  title: Text;
  category_assets: {
    asset_key: string;
    background_color: string;
  };
  category_type: string;
  endpoint: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.title = new Text(data.title);
    this.category_assets = {
      asset_key: data.categoryAssets?.assetKey,
      background_color: data.categoryAssets?.backgroundColor
    };
    this.category_type = data.categoryType;
    this.endpoint = new NavigationEndpoint(data.endpoint);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/classes/ytkids/KidsHomeScreen.ts:

import { Parser } from '../../index.js';
import AnchoredSection from './AnchoredSection.js';
import { type ObservedArray, YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';

export default class KidsHomeScreen extends YTNode {
  static type = 'kidsHomeScreen';

  anchors: ObservedArray<AnchoredSection>;

  constructor(data: RawNode) {
    super();
    this.anchors = Parser.parseArray(data.anchors, AnchoredSection);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/continuations.ts:

import { YTNode, observe } from './helpers.js';
import { Thumbnail } from './misc.js';
import { NavigationEndpoint, LiveChatItemList, LiveChatHeader, LiveChatParticipantsList, Message } from './nodes.js';
import * as Parser from './parser.js';

import type { RawNode } from './index.js';
import type { ObservedArray } from './helpers.js';

export class ItemSectionContinuation extends YTNode {
  static readonly type = 'itemSectionContinuation';

  contents: ObservedArray<YTNode> | null;
  continuation?: string;

  constructor(data: RawNode) {
    super();
    this.contents = Parser.parseArray(data.contents);
    if (Array.isArray(data.continuations)) {
      this.continuation = data.continuations?.at(0)?.nextContinuationData?.continuation;
    }
  }
}

export class NavigateAction extends YTNode {
  static readonly type = 'navigateAction';

  endpoint: NavigationEndpoint;

  constructor(data: RawNode) {
    super();
    this.endpoint = new NavigationEndpoint(data.endpoint);
  }
}

export class ShowMiniplayerCommand extends YTNode {
  static readonly type = 'showMiniplayerCommand';

  miniplayer_command: NavigationEndpoint;
  show_premium_branding: boolean;

  constructor(data: RawNode) {
    super();
    this.miniplayer_command = new NavigationEndpoint(data.miniplayerCommand);
    this.show_premium_branding = data.showPremiumBranding;
  }
}

export { default as AppendContinuationItemsAction } from './classes/actions/AppendContinuationItemsAction.js';

export class ReloadContinuationItemsCommand extends YTNode {
  static readonly type = 'reloadContinuationItemsCommand';

  target_id: string;
  contents: ObservedArray<YTNode> | null;
  slot?: string;

  constructor(data: RawNode) {
    super();
    this.target_id = data.targetId;
    this.contents = Parser.parse(data.continuationItems, true);
    this.slot = data?.slot;
  }
}

export class SectionListContinuation extends YTNode {
  static readonly type = 'sectionListContinuation';

  continuation: string;
  contents: ObservedArray<YTNode> | null;

  constructor(data: RawNode) {
    super();
    this.contents = Parser.parse(data.contents, true);
    this.continuation =
      data.continuations?.[0]?.nextContinuationData?.continuation ||
      data.continuations?.[0]?.reloadContinuationData?.continuation || null;
  }
}

export class MusicPlaylistShelfContinuation extends YTNode {
  static readonly type = 'musicPlaylistShelfContinuation';

  continuation: string;
  contents: ObservedArray<YTNode> | null;

  constructor(data: RawNode) {
    super();
    this.contents = Parser.parse(data.contents, true);
    this.continuation = data.continuations?.[0].nextContinuationData.continuation || null;
  }
}

export class MusicShelfContinuation extends YTNode {
  static readonly type = 'musicShelfContinuation';

  continuation: string;
  contents: ObservedArray<YTNode> | null;

  constructor(data: RawNode) {
    super();
    this.contents = Parser.parseArray(data.contents);
    this.continuation =
      data.continuations?.[0].nextContinuationData?.continuation ||
      data.continuations?.[0].reloadContinuationData?.continuation || null;
  }
}

export class GridContinuation extends YTNode {
  static readonly type = 'gridContinuation';

  continuation: string;
  items: ObservedArray<YTNode> | null;

  constructor(data: RawNode) {
    super();
    this.items = Parser.parse(data.items, true);
    this.continuation = data.continuations?.[0].nextContinuationData.continuation || null;
  }

  get contents() {
    return this.items;
  }
}

export class PlaylistPanelContinuation extends YTNode {
  static readonly type = 'playlistPanelContinuation';

  continuation: string;
  contents: ObservedArray<YTNode> | null;

  constructor(data: RawNode) {
    super();
    this.contents = Parser.parseArray(data.contents);
    this.continuation = data.continuations?.[0]?.nextContinuationData?.continuation ||
      data.continuations?.[0]?.nextRadioContinuationData?.continuation || null;
  }
}

export class Continuation extends YTNode {
  static readonly type = 'continuation';

  continuation_type: string;
  timeout_ms?: number;
  time_until_last_message_ms?: number;
  token: string;

  constructor(data: RawNode) {
    super();
    this.continuation_type = data.type;
    this.timeout_ms = data.continuation?.timeoutMs;
    this.time_until_last_message_ms = data.continuation?.timeUntilLastMessageMsec;
    this.token = data.continuation?.continuation;
  }
}

export class LiveChatContinuation extends YTNode {
  static readonly type = 'liveChatContinuation';

  actions: ObservedArray<YTNode>;
  action_panel: YTNode | null;
  item_list: LiveChatItemList | null;
  header: LiveChatHeader | null;
  participants_list: LiveChatParticipantsList | null;
  popout_message: Message | null;
  emojis: {
    emoji_id: string;
    shortcuts: string[];
    search_terms: string[];
    image: Thumbnail[];
  }[];
  continuation: Continuation;
  viewer_name: string;

  constructor(data: RawNode) {
    super();
    this.actions = Parser.parse(data.actions?.map((action: any) => {
      delete action.clickTrackingParams;
      return action;
    }), true) || observe<YTNode>([]);

    this.action_panel = Parser.parseItem(data.actionPanel);
    this.item_list = Parser.parseItem(data.itemList, LiveChatItemList);
    this.header = Parser.parseItem(data.header, LiveChatHeader);
    this.participants_list = Parser.parseItem(data.participantsList, LiveChatParticipantsList);
    this.popout_message = Parser.parseItem(data.popoutMessage, Message);

    this.emojis = data.emojis?.map((emoji: any) => ({
      emoji_id: emoji.emojiId,
      shortcuts: emoji.shortcuts,
      search_terms: emoji.searchTerms,
      image: Thumbnail.fromResponse(emoji.image),
      is_custom_emoji: emoji.isCustomEmoji
    })) || [];

    let continuation, type;

    if (data.continuations?.[0].timedContinuationData) {
      type = 'timed';
      continuation = data.continuations?.[0].timedContinuationData;
    } else if (data.continuations?.[0].invalidationContinuationData) {
      type = 'invalidation';
      continuation = data.continuations?.[0].invalidationContinuationData;
    } else if (data.continuations?.[0].liveChatReplayContinuationData) {
      type = 'replay';
      continuation = data.continuations?.[0].liveChatReplayContinuationData;
    }

    this.continuation = new Continuation({ continuation, type });

    this.viewer_name = data.viewerName;
  }
}

export class ContinuationCommand extends YTNode {
  static readonly type = 'ContinuationCommand';

  request: string;
  token: string;

  constructor(data: RawNode) {
    super();
    this.request = data.request;
    this.token = data.token;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/generator.ts:

/* eslint-disable no-cond-assign */
import { YTNode } from './helpers.js';
import * as Parser from './parser.js';
import { InnertubeError } from '../utils/Utils.js';

import Author from './classes/misc/Author.js';
import Text from './classes/misc/Text.js';
import Thumbnail from './classes/misc/Thumbnail.js';
import NavigationEndpoint from './classes/NavigationEndpoint.js';

import type { YTNodeConstructor } from './helpers.js';

export type MiscInferenceType = {
  type: 'misc',
  misc_type: 'NavigationEndpoint',
  optional: boolean,
  endpoint: NavigationEndpoint
} | {
  type: 'misc',
  misc_type: 'Text',
  optional: boolean,
  text: string,
  endpoint?: NavigationEndpoint
} | {
  type: 'misc',
  misc_type: 'Thumbnail',
  optional: boolean,
} | {
  type: 'misc',
  misc_type: 'Author',
  optional: boolean,
  params: [string, string?],
}

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' | '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([
  'trackingParams', 'accessibility', 'accessibilityData'
]);

const RENDERER_EXAMPLES: Record<string, unknown> = {};

export function camelToSnake(str: string) {
  return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
}

/**
 * Infer the type of a key given its value
 * @param key - The key to infer the type of
 * @param value - The value of the key
 * @returns The inferred type
 */
export function inferType(key: string, value: unknown): InferenceType {
  let return_value: string | Record<string, any> | 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]);
      return {
        type: 'renderer',
        renderers: [ return_value ],
        optional: false
      };
    }
    if (return_value = isRendererList(value)) {
      for (const [ key, value ] of Object.entries(return_value)) {
        RENDERER_EXAMPLES[key] = value;
      }
      return {
        type: 'array',
        array_type: 'renderer',
        renderers: Object.keys(return_value),
        optional: false
      };
    }
    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')
    return {
      type: 'object',
      keys: Object.entries(value as object).map(([ key, value ]) => [ key, inferType(key, value) ]),
      optional: false
    };
  return {
    type: 'primative',
    typeof: [ primative_type ],
    optional: false
  };
}

/**
 * Checks if the given value is an array of renderers
 * @param value - The value to check
 * @returns If it is a renderer list, return an object with keys being the classnames, and values being an example of that class.
 * Otherwise, return false.
 */
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 ?
      Object.fromEntries(value.map((item) => {
        const key = Reflect.ownKeys(item)[0].toString();
        return [ Parser.sanitizeClassName(key), item[key] ];
      })) :
      false
  );
}

/**
 * Check if the given value is a misc type.
 * @param key - The key of the value
 * @param value - The value to check
 * @returns If it is a misc type, return the InferenceType. Otherwise, return false.
 */
export function isMiscType(key: string, value: unknown): MiscInferenceType | false {
  if (typeof value === 'object' && value !== null) {
    // NavigationEndpoint
    if (key.endsWith('Endpoint') || key.endsWith('Command') || key === 'endpoint') {
      return {
        type: 'misc',
        endpoint: new NavigationEndpoint(value),
        optional: false,
        misc_type: 'NavigationEndpoint'
      };
    }
    // Text
    if (Reflect.has(value, 'simpleText') || Reflect.has(value, 'runs')) {
      const textNode = new Text(value);
      return {
        type: 'misc',
        misc_type: 'Text',
        optional: false,
        endpoint: textNode.endpoint,
        text: textNode.toString()
      };
    }
    // Thumbnail
    if (Reflect.has(value, 'thumbnails') && Array.isArray(Reflect.get(value, 'thumbnails'))) {
      return {
        type: 'misc',
        misc_type: 'Thumbnail',
        optional: false
      };
    }
  }
  return false;
}

/**
 * Check if the given value is a renderer
 * @param value - The value to check
 * @returns If it is a renderer, return the class name. Otherwise, return false.
 */
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) {
    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', {
      classdata
    });
  }

  const keys = Reflect.ownKeys(classdata)
    .filter((key) => !isIgnoredKey(key))
    .filter((key): key is string => typeof key === 'string');

  return keys.map((key) => {
    const value = Reflect.get(classdata, key) as unknown;
    const inferred_type = inferType(key, value);
    return [ key, inferred_type ] as const;
  });
}

function introspectKeysSecondPass(key_info: KeyInfo) {
  // The second pass will detect Author
  const channel_nav = key_info.filter(([ , value ]) => {
    if (value.type !== 'misc') return false;
    if (!(value.misc_type === 'NavigationEndpoint' || value.misc_type === 'Text')) return false;
    return value.endpoint?.metadata.page_type === 'WEB_PAGE_TYPE_CHANNEL';
  });

  // Whichever one has the longest text is the most probable match
  const most_probable_match = channel_nav.sort(([ , a ], [ , b ]) => {
    if (a.type !== 'misc' || b.type !== 'misc') return 0;
    if (a.misc_type !== 'Text' || b.misc_type !== 'Text') return 0;
    return b.text.length - a.text.length;
  });

  const excluded_keys = new Set<string>();

  const cannonical_channel_nav = most_probable_match[0];

  let author: MiscInferenceType | undefined;
  // We've found an author
  if (cannonical_channel_nav) {
    excluded_keys.add(cannonical_channel_nav[0]);
    // Now to locate its metadata
    // We'll first get all the keys in the classdata
    const keys = key_info.map(([ key ]) => key);
    // Check for anything ending in 'Badges' equals 'badges'
    const badges = keys.filter((key) => key.endsWith('Badges') || key === 'badges');
    // The likely candidate is the one with some prefix (owner, author)
    const likely_badges = badges.filter((key) => key.startsWith('owner') || key.startsWith('author'));
    // If we have a likely candidate, we'll use that
    const cannonical_badges = likely_badges[0] ?? badges[0];
    // Now we have the author and its badges
    // 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 === '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);
    // TODO: next we check for the author's thumbnail
    author = {
      type: 'misc',
      misc_type: 'Author',
      optional: false,
      params: [
        cannonical_channel_nav[0],
        is_badges ? cannonical_badges : undefined
      ]
    };
  }

  if (author) {
    key_info.push([ 'author', author ]);
  }

  return key_info.filter(([ key ]) => !excluded_keys.has(key));
}

function introspect2(classdata: unknown) {
  const key_info = introspectKeysFirstPass(classdata);
  return introspectKeysSecondPass(key_info);
}

/**
 * Introspect an example of a class in order to determine its key info and dependencies
 * @param classdata - The example of the class
 * @returns The key info and any unimplemented dependencies
 */
export function introspect(classdata: unknown) {
  const key_info = introspect2(classdata);
  const dependencies = new Map<string, any>();
  for (const [ , value ] of key_info) {
    if (value.type === 'renderer' || (value.type === 'array' && value.array_type === 'renderer'))
      for (const renderer of value.renderers) {
        const example = RENDERER_EXAMPLES[renderer];
        if (example)
          dependencies.set(renderer, example);
      }
  }
  const unimplemented_dependencies = Array.from(dependencies).filter(([ classname ]) => !Parser.hasParser(classname));

  return {
    key_info,
    unimplemented_dependencies
  };
}

/**
 * Is this key ignored by the parser?
 * @param key - The key to check
 * @returns Whether or not the key is ignored
 */
export function isIgnoredKey(key: string | symbol) {
  return typeof key === 'string' && IGNORED_KEYS.has(key);
}

/**
 * Given a classname and its resolved key info, create a new class
 * @param classname - The name of the class
 * @param key_info - The resolved key info
 * @returns Class based on the key info extending YTNode
 */
export function createRuntimeClass(classname: string, key_info: KeyInfo, logger: Parser.ParserErrorHandler): YTNodeConstructor {
  logger({
    error_type: 'class_not_found',
    classname,
    key_info
  });

  const node = class extends YTNode {
    static type = classname;
    static #key_info = new Map<string, InferenceType>();
    static set key_info(key_info: KeyInfo) {
      this.#key_info = new Map(key_info);
    }
    static get key_info() {
      return [ ...this.#key_info.entries() ];
    }
    constructor(data: unknown) {
      super();
      const {
        key_info,
        unimplemented_dependencies
      } = introspect(data);

      const {
        resolved_key_info,
        changed_keys
      } = mergeKeyInfo(node.key_info, key_info);

      const did_change = changed_keys.length > 0;

      if (did_change) {
        node.key_info = resolved_key_info;
        logger({
          error_type: 'class_changed',
          classname,
          key_info: node.key_info,
          changed_keys
        });
      }

      for (const [ name, data ] of unimplemented_dependencies)
        generateRuntimeClass(name, data, logger);

      for (const [ key, value ] of key_info) {
        let snake_key = camelToSnake(key);
        if (value.type === 'misc' && value.misc_type === 'NavigationEndpoint')
          snake_key = 'endpoint';
        Reflect.set(this, snake_key, parse(key, value, data));
      }
    }
  };
  node.key_info = key_info;
  Object.defineProperty(node, 'name', { value: classname, writable: false });
  return node;
}

/**
 * Given example data for a class, introspect, implement dependencies, and create a new class
 * @param classname - The name of the class
 * @param classdata - The example of the class
 * @returns Class based on the example classdata extending YTNode
 */
export function generateRuntimeClass(classname: string, classdata: unknown, logger: Parser.ParserErrorHandler) {
  const {
    key_info,
    unimplemented_dependencies
  } = introspect(classdata);

  const JITNode = createRuntimeClass(classname, key_info, logger);
  Parser.addRuntimeParser(classname, JITNode);

  for (const [ name, data ] of unimplemented_dependencies)
    generateRuntimeClass(name, data, logger);

  return JITNode;
}

/**
 * Generate a typescript class based on the key info
 * @param classname - The name of the class
 * @param key_info - The key info, as returned by {@link introspect}
 * @returns Typescript class file
 */
export function generateTypescriptClass(classname: string, key_info: KeyInfo) {
  const props: string[] = [];
  const constructor_lines = [
    'super();'
  ];
  for (const [ key, value ] of key_info) {
    let snake_key = camelToSnake(key);
    if (value.type === 'misc' && value.misc_type === 'NavigationEndpoint')
      snake_key = 'endpoint';
    props.push(`${snake_key}${value.optional ? '?' : ''}: ${toTypeDeclaration(value)};`);
    constructor_lines.push(`this.${snake_key} = ${toParser(key, value)};`);
  }
  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
 * @param indentation - The indentation level (used for objects)
 * @returns Typescript type declaration
 */
export function toTypeDeclaration(inference_type: InferenceType, indentation = 0): string {
  switch (inference_type.type) {
    case 'renderer':
    {
      return `${inference_type.renderers.map((type) => `YTNodes.${type}`).join(' | ')} | null`;
    }
    case 'array':
    {
      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 toTypeDeclarationObject(indentation, inference_type.keys);
    }
    case 'misc':
      switch (inference_type.misc_type) {
        case 'Thumbnail':
          return 'Thumbnail[]';
        default:
          return inference_type.misc_type;
      }
    case 'primative':
      return inference_type.typeof.join(' | ');
  }
}

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
 * @param inference_type - The inference type to parse
 * @param key_path - The path to the key (excluding the key itself)
 * @param indentation - The indentation level (used for objects)
 * @returns Statement to parse the given key
 */
export function toParser(key: string, inference_type: InferenceType, key_path: string[] = [ 'data' ], indentation = 1) {
  let parser = 'undefined';
  switch (inference_type.type) {
    case 'renderer':
      {
        parser = `Parser.parseItem(${key_path.join('.')}.${key}, ${toParserValidTypes(inference_type.renderers)})`;
      }
      break;
    case 'array':
      {
        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':
      {
        parser = toParserObject(indentation, inference_type.keys, key_path, key);
      }
      break;
    case 'misc':
      switch (inference_type.misc_type) {
        case 'Thumbnail':
          parser = `Thumbnail.fromResponse(${key_path.join('.')}.${key})`;
          break;
        case 'Author':
        {
          const author_parser = `new Author(${key_path.join('.')}.${inference_type.params[0]}, ${inference_type.params[1] ? `${key_path.join('.')}.${inference_type.params[1]}` : 'undefined'})`;
          if (inference_type.optional)
            return `Reflect.has(${key_path.join('.')}, '${inference_type.params[0]}') ? ${author_parser} : undefined`;
          return author_parser;
        }
        default:
          parser = `new ${inference_type.misc_type}(${key_path.join('.')}.${key})`;
          break;
      }
      if (parser === 'undefined')
        throw new Error('Unreachable code reached! Switch missing case!');
      break;
    case 'primative':
      parser = `${key_path.join('.')}.${key}`;
      break;
  }
  if (inference_type.optional)
    return `Reflect.has(${key_path.join('.')}, '${key}') ? ${parser} : undefined`;
  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)
    data = data[key];
  return data;
}

function hasDataFromKeyPath(root: any, key_path: string[]) {
  let data = root;
  for (const key of key_path)
    if (!Reflect.has(data, key))
      return false;
    else
      data = data[key];
  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
 * @param inference_type - The inference type to parse
 * @param data - The data to parse from
 * @param key_path - The path to the key (excluding the key itself)
 * @returns The parsed value
 */
export function parse(key: string, inference_type: InferenceType, data: unknown, key_path: string[] = [ 'data' ]) {
  const should_optional = !inference_type.optional || hasDataFromKeyPath({ data }, [ ...key_path, key ]);
  switch (inference_type.type) {
    case 'renderer':
    {
      return should_optional ? Parser.parseItem(accessDataFromKeyPath({ data }, [ ...key_path, key ]), inference_type.renderers.map((type) => Parser.getParserByName(type))) : undefined;
    }
    case 'array':
    {
      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':
    {
      return parseObject(key, data, key_path, inference_type.keys, should_optional);
    }
    case 'misc':
      switch (inference_type.misc_type) {
        case 'NavigationEndpoint':
          return should_optional ? new NavigationEndpoint(accessDataFromKeyPath({ data }, [ ...key_path, key ])) : undefined;
        case 'Text':
          return should_optional ? new Text(accessDataFromKeyPath({ data }, [ ...key_path, key ])) : undefined;
        case 'Thumbnail':
          return should_optional ? Thumbnail.fromResponse(accessDataFromKeyPath({ data }, [ ...key_path, key ])) : undefined;
        case 'Author':
        {
          const author_should_optional = !inference_type.optional || hasDataFromKeyPath({ data }, [ ...key_path, inference_type.params[0] ]);
          return author_should_optional ? new Author(
            accessDataFromKeyPath({ data }, [ ...key_path, inference_type.params[0] ]),
            inference_type.params[1] ?
              accessDataFromKeyPath({ data }, [ ...key_path, inference_type.params[1] ]) : undefined
          ) : undefined;
        }
      }
      throw new Error('Unreachable code reached! Switch missing case!');
    case 'primative':
      return accessDataFromKeyPath({ data }, [ ...key_path, key ]);
  }
}

/**
   * Merges two sets of key info, resolving any conflicts
   * @param key_info - The current key info
   * @param new_key_info - The new key info
   * @returns The merged key info
   */
export function mergeKeyInfo(key_info: KeyInfo, new_key_info: KeyInfo) {
  const changed_keys = new Map<string, InferenceType>();
  const current_keys = new Set(key_info.map(([ key ]) => key));
  const new_keys = new Set(new_key_info.map(([ key ]) => key));

  const added_keys = new_key_info.filter(([ key ]) => !current_keys.has(key));
  const removed_keys = key_info.filter(([ key ]) => !new_keys.has(key));

  const common_keys = key_info.filter(([ key ]) => new_keys.has(key));

  const new_key_map = new Map(new_key_info);

  for (const [ key, type ] of common_keys) {
    const new_type = new_key_map.get(key);
    if (!new_type) continue;
    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: 'primative',
        typeof: [ 'unknown' ],
        optional: true
      });
      continue;
    }
    // We've got the same type, so we can now resolve the changes
    switch (type.type) {
      case 'object':
        {
          if (new_type.type !== 'object') continue;
          const { resolved_key_info } = mergeKeyInfo(type.keys, new_type.keys);
          const resolved_key: InferenceType = {
            type: 'object',
            keys: resolved_key_info,
            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 'renderer':
        {
          if (new_type.type !== 'renderer') continue;
          const union_map = {
            ...type.renderers,
            ...new_type.renderers
          };
          const either_optional = type.optional || new_type.optional;
          const resolved_key: InferenceType = {
            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 'array':
        {
          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':
        {
          if (new_type.type !== 'misc') continue;
          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: 'primative',
              typeof: [ 'unknown' ],
              optional: true
            });
          }
          switch (type.misc_type) {
            case 'Author':
              {
                if (new_type.misc_type !== 'Author') break;
                const had_optional_param = type.params[1] || new_type.params[1];
                const either_optional = type.optional || new_type.optional;
                const resolved_key: MiscInferenceType = {
                  type: 'misc',
                  misc_type: 'Author',
                  optional: either_optional,
                  params: [ new_type.params[0], had_optional_param ]
                };
                const did_change = JSON.stringify(resolved_key) !== JSON.stringify(type);
                if (did_change) changed_keys.set(key, resolved_key);
              }
              break;
            // Other cases can not change
          }
        }
        break;
      case 'primative':
        {
          if (new_type.type !== 'primative') continue;
          const resolved_key: InferenceType = {
            type: 'primative',
            typeof: Array.from(new Set([ ...new_type.typeof, ...type.typeof ])),
            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;
    }
  }

  for (const [ key, type ] of added_keys) {
    changed_keys.set(key, {
      ...type,
      optional: true
    });
  }

  for (const [ key, type ] of removed_keys) {
    changed_keys.set(key, {
      ...type,
      optional: true
    });
  }

  const unchanged_keys = key_info.filter(([ key ]) => !changed_keys.has(key));

  const resolved_key_info_map = new Map([ ...unchanged_keys, ...changed_keys ]);
  const resolved_key_info = [ ...resolved_key_info_map.entries() ];

  return {
    resolved_key_info,
    changed_keys: [ ...changed_keys.entries() ]
  };
}

LuanRT/YouTube.js/blob/main/src/parser/helpers.ts:

import Log from '../utils/Log.js';
import { deepCompare, ParsingError } from '../utils/Utils.js';

const isObserved = Symbol('ObservedArray.isObserved');

export class YTNode {
  static readonly type: string = 'YTNode';
  readonly type: string;

  constructor() {
    this.type = (this.constructor as YTNodeConstructor).type;
  }

  /**
   * Check if the node is of the given type.
   * @param type - The type to check
   * @returns whether the node is of the given type
   */
  #is<T extends YTNode>(type: YTNodeConstructor<T>): this is T {
    return this.type === type.type;
  }

  /**
   * Check if the node is of the given type.
   * @param types - The type to check
   * @returns whether the node is of the given type
   */
  is<T extends YTNode, K extends YTNodeConstructor<T>[]>(...types: K): this is InstanceType<K[number]> {
    return types.some((type) => this.#is(type));
  }

  /**
   * Cast to one of the given types.
   */
  as<T extends YTNode, K extends YTNodeConstructor<T>[]>(...types: K): InstanceType<K[number]> {
    if (!this.is(...types)) {
      throw new ParsingError(`Cannot cast ${this.type} to one of ${types.map((t) => t.type).join(', ')}`);
    }
    return this;
  }

  /**
   * Check for a key without asserting the type.
   * @param key - The key to check
   * @returns Whether the node has the key
   */
  hasKey<T extends string, R = any>(key: T): this is this & { [k in T]: R } {
    return Reflect.has(this, key);
  }

  /**
   * Assert that the node has the given key and return it.
   * @param key - The key to check
   * @returns The value of the key wrapped in a Maybe
   * @throws If the node does not have the key
   */
  key<T extends string, R = any>(key: T) {
    if (!this.hasKey<T, R>(key)) {
      throw new ParsingError(`Missing key ${key}`);
    }
    return new Maybe(this[key]);
  }
}

export class Maybe {
  #TAG = 'Maybe';
  #value;

  constructor (value: any) {
    this.#value = value;
  }

  #checkPrimative(type: 'string' | 'number' | 'bigint' | 'boolean' | 'symbol' | 'undefined' | 'object' | 'function') {
    if (typeof this.#value !== type) {
      return false;
    }
    return true;
  }

  #assertPrimative(type: 'string' | 'number' | 'bigint' | 'boolean' | 'symbol' | 'undefined' | 'object' | 'function') {
    if (!this.#checkPrimative(type)) {
      throw new TypeError(`Expected ${type}, got ${this.typeof}`);
    }
    return this.#value;
  }

  get typeof() {
    return typeof this.#value;
  }

  string(): string {
    return this.#assertPrimative('string');
  }

  isString() {
    return this.#checkPrimative('string');
  }

  number(): number {
    return this.#assertPrimative('number');
  }

  isNumber() {
    return this.#checkPrimative('number');
  }

  bigint(): bigint {
    return this.#assertPrimative('bigint');
  }

  isBigint() {
    return this.#checkPrimative('bigint');
  }

  boolean(): boolean {
    return this.#assertPrimative('boolean');
  }

  isBoolean() {
    return this.#checkPrimative('boolean');
  }

  symbol(): symbol {
    return this.#assertPrimative('symbol');
  }

  isSymbol() {
    return this.#checkPrimative('symbol');
  }

  undefined(): undefined {
    return this.#assertPrimative('undefined');
  }

  isUndefined() {
    return this.#checkPrimative('undefined');
  }

  null(): null {
    if (this.#value !== null)
      throw new TypeError(`Expected null, got ${typeof this.#value}`);
    return this.#value;
  }

  isNull() {
    return this.#value === null;
  }

  object(): object {
    return this.#assertPrimative('object');
  }

  isObject() {
    return this.#checkPrimative('object');
  }

  /* eslint-ignore */
  function(): Function {
    return this.#assertPrimative('function');
  }

  isFunction() {
    return this.#checkPrimative('function');
  }

  /**
   * Get the value as an array.
   * @returns the value as any[]
   * @throws If the value is not an array
   */
  array(): any[] {
    if (!Array.isArray(this.#value)) {
      throw new TypeError(`Expected array, got ${typeof this.#value}`);
    }
    return this.#value;
  }

  /**
   * More typesafe variant of {@link Maybe#array}.
   * @returns a proxied array which returns all the values as {@link Maybe}
   * @throws If the value is not an array
   */
  arrayOfMaybe(): Maybe[] {
    const arrayProps: any[] = [];
    return new Proxy(this.array(), {
      get(target, prop) {
        if (Reflect.has(arrayProps, prop)) {
          return Reflect.get(target, prop);
        }
        return new Maybe(Reflect.get(target, prop));
      }
    });
  }

  /**
   * Check whether the value is an array.
   * @returns whether the value is an array
   */
  isArray() {
    return Array.isArray(this.#value);
  }

  /**
   * Get the value as a YTNode
   * @returns the value as a YTNode
   * @throws If the value is not a YTNode
   */
  node() {
    if (!(this.#value instanceof YTNode)) {
      throw new TypeError(`Expected YTNode, got ${this.#value.constructor.name}`);
    }
    return this.#value;
  }

  /**
   * Check if the value is a YTNode
   * @returns Whether the value is a YTNode
   */
  isNode() {
    return this.#value instanceof YTNode;
  }

  /**
   * Get the value as a YTNode of the given type.
   * @param type - The type to cast to
   * @returns The node casted to the given type
   * @throws If the node is not of the given type
   */
  nodeOfType<T extends YTNode, K extends YTNodeConstructor<T>[]>(...types: K) {
    return this.node().as(...types);
  }

  /**
   * Check if the value is a YTNode of the given type.
   * @param type - the type to check
   * @returns Whether the value is a YTNode of the given type
   */
  isNodeOfType<T extends YTNode, K extends YTNodeConstructor<T>[]>(...types: K) {
    return this.isNode() && this.node().is(...types);
  }

  /**
   * Get the value as an ObservedArray.
   * @returns the value of the Maybe as a ObservedArray
   */
  observed(): ObservedArray<YTNode> {
    if (!this.isObserved()) {
      throw new TypeError(`Expected ObservedArray, got ${typeof this.#value}`);
    }
    return this.#value;
  }

  /**
   * Check if the value is an ObservedArray.
   */
  isObserved() {
    return this.#value?.[isObserved];
  }

  /**
   * Get the value of the Maybe as a SuperParsedResult.
   * @returns the value as a SuperParsedResult
   * @throws If the value is not a SuperParsedResult
   */
  parsed(): SuperParsedResult {
    if (!(this.#value instanceof SuperParsedResult)) {
      throw new TypeError(`Expected SuperParsedResult, got ${typeof this.#value}`);
    }
    return this.#value;
  }

  /**
   * Is the result a SuperParsedResult?
   */
  isParsed() {
    return this.#value instanceof SuperParsedResult;
  }

  /**
   * @deprecated
   * This call is not meant to be used outside of debugging. Please use the specific type getter instead.
   */
  any(): any {
    Log.warn(this.#TAG, 'This call is not meant to be used outside of debugging. Please use the specific type getter instead.');
    return this.#value;
  }

  /**
   * Get the node as an instance of the given class.
   * @param type - The type to check
   * @returns the value as the given type
   * @throws If the node is not of the given type
   */
  instanceof<T extends object>(type: Constructor<T>): T {
    if (!this.isInstanceof(type)) {
      throw new TypeError(`Expected instance of ${type.name}, got ${this.#value.constructor.name}`);
    }
    return this.#value;
  }

  /**
   * Check if the node is an instance of the given class.
   * @param type - The type to check
   * @returns Whether the node is an instance of the given type
   */
  isInstanceof<T extends object>(type: Constructor<T>): this is this & T {
    return this.#value instanceof type;
  }
}

export interface Constructor<T> {
    new (...args: any[]): T;
}

export interface YTNodeConstructor<T extends YTNode = YTNode> {
    new(data: any): T;
    readonly type: string;
}

/**
 * Represents a parsed response in an unknown state. Either a YTNode or a YTNode[] or null.
 */
export class SuperParsedResult<T extends YTNode = YTNode> {
  #result;

  constructor(result: T | ObservedArray<T> | null) {
    this.#result = result;
  }

  get is_null() {
    return this.#result === null;
  }
  get is_array() {
    return !this.is_null && Array.isArray(this.#result);
  }
  get is_node() {
    return !this.is_array;
  }

  array() {
    if (!this.is_array) {
      throw new TypeError('Expected an array, got a node');
    }
    return this.#result as ObservedArray<T>;
  }

  item() {
    if (!this.is_node) {
      throw new TypeError('Expected a node, got an array');
    }
    return this.#result as T;
  }
}


export type ObservedArray<T extends YTNode = YTNode> = Array<T> & {
    /**
     * Returns the first object to match the rule.
     */
    get: (rule: object, del_item?: boolean) => T | undefined;
    /**
     * Returns all objects that match the rule.
     */
    getAll: (rule: object, del_items?: boolean) => T[];
    /**
     * Returns the first object to match the condition.
     */
    matchCondition: (condition: (node: T) => boolean) => T | undefined;
    /**
     * Removes the item at the given index.
     */
    remove: (index: number) => T[];
    /**
     * Get all items of a specific type
     */
    filterType<R extends YTNode, K extends YTNodeConstructor<R>[]>(...types: K): ObservedArray<InstanceType<K[number]>>;
    /**
     * Get the first of a specific type
     */
    firstOfType<R extends YTNode, K extends YTNodeConstructor<R>[]>(...types: K): InstanceType<K[number]> | undefined;
    /**
     * Get the first item
     */
    first: () => T;
    /**
     * This is similar to filter but throws if there's a type mismatch.
     */
    as<R extends YTNode, K extends YTNodeConstructor<R>[]>(...types: K): ObservedArray<InstanceType<K[number]>>;
};

/**
 * Creates a trap to intercept property access
 * and add utilities to an object.
 */
export function observe<T extends YTNode>(obj: Array<T>): ObservedArray<T> {
  return new Proxy(obj, {
    get(target, prop) {
      if (prop == 'get') {
        return (rule: object, del_item?: boolean) => (
          target.find((obj, index) => {
            const match = deepCompare(rule, obj);
            if (match && del_item) {
              target.splice(index, 1);
            }
            return match;
          })
        );
      }

      if (prop == isObserved) {
        return true;
      }

      if (prop == 'getAll') {
        return (rule: object, del_items: boolean) => (
          target.filter((obj, index) => {
            const match = deepCompare(rule, obj);
            if (match && del_items) {
              target.splice(index, 1);
            }
            return match;
          })
        );
      }

      if (prop == 'matchCondition') {
        return (condition: (node: T) => boolean) => (
          target.find((obj) => {
            return condition(obj);
          })
        );
      }

      if (prop == 'filterType') {
        return (...types: YTNodeConstructor<YTNode>[]) => {
          return observe(target.filter((node: YTNode) => {
            if (node.is(...types))
              return true;
            return false;

          }));
        };
      }


      if (prop == 'firstOfType') {
        return (...types: YTNodeConstructor<YTNode>[]) => {
          return target.find((node: YTNode) => {
            if (node.is(...types))
              return true;
            return false;
          });
        };
      }

      if (prop == 'first') {
        return () => target[0];
      }

      if (prop == 'as') {
        return (...types: YTNodeConstructor<YTNode>[]) => {
          return observe(target.map((node: YTNode) => {
            if (node.is(...types))
              return node;
            throw new ParsingError(`Expected node of any type ${types.map((type) => type.type).join(', ')}, got ${(node as YTNode).type}`);
          }));
        };
      }

      if (prop == 'remove') {
        return (index: number): any => target.splice(index, 1);
      }

      return Reflect.get(target, prop);
    }
  }) as ObservedArray<T>;
}

export class Memo extends Map<string, YTNode[]> {
  getType<T extends YTNode, K extends YTNodeConstructor<T>[]>(types: K): ObservedArray<InstanceType<K[number]>>;
  getType<T extends YTNode, K extends YTNodeConstructor<T>[]>(...types: K): ObservedArray<InstanceType<K[number]>>
  getType(...types: YTNodeConstructor<YTNode>[] | YTNodeConstructor<YTNode>[][]) {
    types = types.flat();
    return observe(types.flatMap((type) => (this.get(type.type) || []) as YTNode[]));
  }
}

LuanRT/YouTube.js/blob/main/src/parser/index.ts:

export * as Parser from './parser.js';
export * from './continuations.js';
export * from './types/index.js';
export * as Misc from './misc.js';
export * as YTNodes from './nodes.js';
export * as YT from './youtube/index.js';
export * as YTMusic from './ytmusic/index.js';
export * as YTKids from './ytkids/index.js';
export * as YTShorts from './ytshorts/index.js';
export * as Helpers from './helpers.js';
export * as Generator from './generator.js';

LuanRT/YouTube.js/blob/main/src/parser/misc.ts:

// This file was auto generated, do not edit.
// See ./scripts/build-parser-map.js

export { default as Author } from './classes/misc/Author.js';
export { default as ChildElement } from './classes/misc/ChildElement.js';
export { default as EmojiRun } from './classes/misc/EmojiRun.js';
export { default as Format } from './classes/misc/Format.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';
export { default as VideoDetails } from './classes/misc/VideoDetails.js';

LuanRT/YouTube.js/blob/main/src/parser/nodes.ts:

// This file was auto generated, do not edit.
// See ./scripts/build-parser-map.js

export { default as AboutChannel } from './classes/AboutChannel.js';
export { default as AboutChannelView } from './classes/AboutChannelView.js';
export { default as AccountChannel } from './classes/AccountChannel.js';
export { default as AccountItemSection } from './classes/AccountItemSection.js';
export { default as AccountItemSectionHeader } from './classes/AccountItemSectionHeader.js';
export { default as AccountSectionList } from './classes/AccountSectionList.js';
export { default as AppendContinuationItemsAction } from './classes/actions/AppendContinuationItemsAction.js';
export { default as OpenPopupAction } from './classes/actions/OpenPopupAction.js';
export { default as UpdateEngagementPanelAction } from './classes/actions/UpdateEngagementPanelAction.js';
export { default as Alert } from './classes/Alert.js';
export { default as AlertWithButton } from './classes/AlertWithButton.js';
export { default as AnalyticsMainAppKeyMetrics } from './classes/analytics/AnalyticsMainAppKeyMetrics.js';
export { default as AnalyticsRoot } from './classes/analytics/AnalyticsRoot.js';
export { default as AnalyticsShortsCarouselCard } from './classes/analytics/AnalyticsShortsCarouselCard.js';
export { default as AnalyticsVideo } from './classes/analytics/AnalyticsVideo.js';
export { default as AnalyticsVodCarouselCard } from './classes/analytics/AnalyticsVodCarouselCard.js';
export { default as CtaGoToCreatorStudio } from './classes/analytics/CtaGoToCreatorStudio.js';
export { default as DataModelSection } from './classes/analytics/DataModelSection.js';
export { default as StatRow } from './classes/analytics/StatRow.js';
export { default as AttributionView } from './classes/AttributionView.js';
export { default as AudioOnlyPlayability } from './classes/AudioOnlyPlayability.js';
export { default as AutomixPreviewVideo } from './classes/AutomixPreviewVideo.js';
export { default as AvatarView } from './classes/AvatarView.js';
export { default as BackstageImage } from './classes/BackstageImage.js';
export { default as BackstagePost } from './classes/BackstagePost.js';
export { default as BackstagePostThread } from './classes/BackstagePostThread.js';
export { default as BrowseFeedActions } from './classes/BrowseFeedActions.js';
export { default as BrowserMediaSession } from './classes/BrowserMediaSession.js';
export { default as Button } from './classes/Button.js';
export { default as ButtonView } from './classes/ButtonView.js';
export { default as C4TabbedHeader } from './classes/C4TabbedHeader.js';
export { default as CallToActionButton } from './classes/CallToActionButton.js';
export { default as Card } from './classes/Card.js';
export { default as CardCollection } from './classes/CardCollection.js';
export { default as CarouselHeader } from './classes/CarouselHeader.js';
export { default as CarouselItem } from './classes/CarouselItem.js';
export { default as CarouselLockup } from './classes/CarouselLockup.js';
export { default as Channel } from './classes/Channel.js';
export { default as ChannelAboutFullMetadata } from './classes/ChannelAboutFullMetadata.js';
export { default as ChannelAgeGate } from './classes/ChannelAgeGate.js';
export { default as ChannelExternalLinkView } from './classes/ChannelExternalLinkView.js';
export { default as ChannelFeaturedContent } from './classes/ChannelFeaturedContent.js';
export { default as ChannelHeaderLinks } from './classes/ChannelHeaderLinks.js';
export { default as ChannelHeaderLinksView } from './classes/ChannelHeaderLinksView.js';
export { default as ChannelMetadata } from './classes/ChannelMetadata.js';
export { default as ChannelMobileHeader } from './classes/ChannelMobileHeader.js';
export { default as ChannelOptions } from './classes/ChannelOptions.js';
export { default as ChannelOwnerEmptyState } from './classes/ChannelOwnerEmptyState.js';
export { default as ChannelSubMenu } from './classes/ChannelSubMenu.js';
export { default as ChannelTagline } from './classes/ChannelTagline.js';
export { default as ChannelThumbnailWithLink } from './classes/ChannelThumbnailWithLink.js';
export { default as ChannelVideoPlayer } from './classes/ChannelVideoPlayer.js';
export { default as Chapter } from './classes/Chapter.js';
export { default as ChildVideo } from './classes/ChildVideo.js';
export { default as ChipBarView } from './classes/ChipBarView.js';
export { default as ChipCloud } from './classes/ChipCloud.js';
export { default as ChipCloudChip } from './classes/ChipCloudChip.js';
export { default as ChipView } from './classes/ChipView.js';
export { default as ClipAdState } from './classes/ClipAdState.js';
export { default as ClipCreation } from './classes/ClipCreation.js';
export { default as ClipCreationScrubber } from './classes/ClipCreationScrubber.js';
export { default as ClipCreationTextInput } from './classes/ClipCreationTextInput.js';
export { default as ClipSection } from './classes/ClipSection.js';
export { default as CollaboratorInfoCardContent } from './classes/CollaboratorInfoCardContent.js';
export { default as CollageHeroImage } from './classes/CollageHeroImage.js';
export { default as CollectionThumbnailView } from './classes/CollectionThumbnailView.js';
export { default as AuthorCommentBadge } from './classes/comments/AuthorCommentBadge.js';
export { default as Comment } from './classes/comments/Comment.js';
export { default as CommentActionButtons } from './classes/comments/CommentActionButtons.js';
export { default as CommentDialog } from './classes/comments/CommentDialog.js';
export { default as CommentReplies } from './classes/comments/CommentReplies.js';
export { default as CommentReplyDialog } from './classes/comments/CommentReplyDialog.js';
export { default as CommentsEntryPointHeader } from './classes/comments/CommentsEntryPointHeader.js';
export { default as CommentsEntryPointTeaser } from './classes/comments/CommentsEntryPointTeaser.js';
export { default as CommentsHeader } from './classes/comments/CommentsHeader.js';
export { default as CommentSimplebox } from './classes/comments/CommentSimplebox.js';
export { default as CommentsSimplebox } from './classes/comments/CommentsSimplebox.js';
export { default as CommentThread } from './classes/comments/CommentThread.js';
export { default as CommentView } from './classes/comments/CommentView.js';
export { default as CreatorHeart } from './classes/comments/CreatorHeart.js';
export { default as EmojiPicker } from './classes/comments/EmojiPicker.js';
export { default as PdgCommentChip } from './classes/comments/PdgCommentChip.js';
export { default as SponsorCommentBadge } from './classes/comments/SponsorCommentBadge.js';
export { default as CompactChannel } from './classes/CompactChannel.js';
export { default as CompactLink } from './classes/CompactLink.js';
export { default as CompactMix } from './classes/CompactMix.js';
export { default as CompactMovie } from './classes/CompactMovie.js';
export { default as CompactPlaylist } from './classes/CompactPlaylist.js';
export { default as CompactStation } from './classes/CompactStation.js';
export { default as CompactVideo } from './classes/CompactVideo.js';
export { default as ConfirmDialog } from './classes/ConfirmDialog.js';
export { default as ContentMetadataView } from './classes/ContentMetadataView.js';
export { default as ContentPreviewImageView } from './classes/ContentPreviewImageView.js';
export { default as ContinuationItem } from './classes/ContinuationItem.js';
export { default as ConversationBar } from './classes/ConversationBar.js';
export { default as CopyLink } from './classes/CopyLink.js';
export { default as CreatePlaylistDialog } from './classes/CreatePlaylistDialog.js';
export { default as DecoratedAvatarView } from './classes/DecoratedAvatarView.js';
export { default as DecoratedPlayerBar } from './classes/DecoratedPlayerBar.js';
export { default as DefaultPromoPanel } from './classes/DefaultPromoPanel.js';
export { default as DescriptionPreviewView } from './classes/DescriptionPreviewView.js';
export { default as DidYouMean } from './classes/DidYouMean.js';
export { default as DislikeButtonView } from './classes/DislikeButtonView.js';
export { default as DownloadButton } from './classes/DownloadButton.js';
export { default as Dropdown } from './classes/Dropdown.js';
export { default as DropdownItem } from './classes/DropdownItem.js';
export { default as DynamicTextView } from './classes/DynamicTextView.js';
export { default as Element } from './classes/Element.js';
export { default as EmergencyOnebox } from './classes/EmergencyOnebox.js';
export { default as EmojiPickerCategory } from './classes/EmojiPickerCategory.js';
export { default as EmojiPickerCategoryButton } from './classes/EmojiPickerCategoryButton.js';
export { default as EmojiPickerUpsellCategory } from './classes/EmojiPickerUpsellCategory.js';
export { default as Endscreen } from './classes/Endscreen.js';
export { default as EndscreenElement } from './classes/EndscreenElement.js';
export { default as EndScreenPlaylist } from './classes/EndScreenPlaylist.js';
export { default as EndScreenVideo } from './classes/EndScreenVideo.js';
export { default as EngagementPanelSectionList } from './classes/EngagementPanelSectionList.js';
export { default as EngagementPanelTitleHeader } from './classes/EngagementPanelTitleHeader.js';
export { default as EomSettingsDisclaimer } from './classes/EomSettingsDisclaimer.js';
export { default as ExpandableMetadata } from './classes/ExpandableMetadata.js';
export { default as ExpandableTab } from './classes/ExpandableTab.js';
export { default as ExpandableVideoDescriptionBody } from './classes/ExpandableVideoDescriptionBody.js';
export { default as ExpandedShelfContents } from './classes/ExpandedShelfContents.js';
export { default as Factoid } from './classes/Factoid.js';
export { default as FancyDismissibleDialog } from './classes/FancyDismissibleDialog.js';
export { default as FeedFilterChipBar } from './classes/FeedFilterChipBar.js';
export { default as FeedNudge } from './classes/FeedNudge.js';
export { default as FeedTabbedHeader } from './classes/FeedTabbedHeader.js';
export { default as FlexibleActionsView } from './classes/FlexibleActionsView.js';
export { default as GameCard } from './classes/GameCard.js';
export { default as GameDetails } from './classes/GameDetails.js';
export { default as Grid } from './classes/Grid.js';
export { default as GridChannel } from './classes/GridChannel.js';
export { default as GridHeader } from './classes/GridHeader.js';
export { default as GridMix } from './classes/GridMix.js';
export { default as GridMovie } from './classes/GridMovie.js';
export { default as GridPlaylist } from './classes/GridPlaylist.js';
export { default as GridShow } from './classes/GridShow.js';
export { default as GridVideo } from './classes/GridVideo.js';
export { default as GuideCollapsibleEntry } from './classes/GuideCollapsibleEntry.js';
export { default as GuideCollapsibleSectionEntry } from './classes/GuideCollapsibleSectionEntry.js';
export { default as GuideDownloadsEntry } from './classes/GuideDownloadsEntry.js';
export { default as GuideEntry } from './classes/GuideEntry.js';
export { default as GuideSection } from './classes/GuideSection.js';
export { default as GuideSubscriptionsSection } from './classes/GuideSubscriptionsSection.js';
export { default as HashtagHeader } from './classes/HashtagHeader.js';
export { default as HashtagTile } from './classes/HashtagTile.js';
export { default as Heatmap } from './classes/Heatmap.js';
export { default as HeatMarker } from './classes/HeatMarker.js';
export { default as HeroPlaylistThumbnail } from './classes/HeroPlaylistThumbnail.js';
export { default as HighlightsCarousel } from './classes/HighlightsCarousel.js';
export { default as HistorySuggestion } from './classes/HistorySuggestion.js';
export { default as HorizontalCardList } from './classes/HorizontalCardList.js';
export { default as HorizontalList } from './classes/HorizontalList.js';
export { default as HorizontalMovieList } from './classes/HorizontalMovieList.js';
export { default as IconLink } from './classes/IconLink.js';
export { default as ImageBannerView } from './classes/ImageBannerView.js';
export { default as IncludingResultsFor } from './classes/IncludingResultsFor.js';
export { default as InfoPanelContainer } from './classes/InfoPanelContainer.js';
export { default as InfoPanelContent } from './classes/InfoPanelContent.js';
export { default as InfoRow } from './classes/InfoRow.js';
export { default as InteractiveTabbedHeader } from './classes/InteractiveTabbedHeader.js';
export { default as ItemSection } from './classes/ItemSection.js';
export { default as ItemSectionHeader } from './classes/ItemSectionHeader.js';
export { default as ItemSectionTab } from './classes/ItemSectionTab.js';
export { default as ItemSectionTabbedHeader } from './classes/ItemSectionTabbedHeader.js';
export { default as LikeButton } from './classes/LikeButton.js';
export { default as LikeButtonView } from './classes/LikeButtonView.js';
export { default as LiveChat } from './classes/LiveChat.js';
export { default as AddBannerToLiveChatCommand } from './classes/livechat/AddBannerToLiveChatCommand.js';
export { default as AddChatItemAction } from './classes/livechat/AddChatItemAction.js';
export { default as AddLiveChatTickerItemAction } from './classes/livechat/AddLiveChatTickerItemAction.js';
export { default as DimChatItemAction } from './classes/livechat/DimChatItemAction.js';
export { default as LiveChatAutoModMessage } from './classes/livechat/items/LiveChatAutoModMessage.js';
export { default as LiveChatBanner } from './classes/livechat/items/LiveChatBanner.js';
export { default as LiveChatBannerHeader } from './classes/livechat/items/LiveChatBannerHeader.js';
export { default as LiveChatBannerPoll } from './classes/livechat/items/LiveChatBannerPoll.js';
export { default as LiveChatMembershipItem } from './classes/livechat/items/LiveChatMembershipItem.js';
export { default as LiveChatPaidMessage } from './classes/livechat/items/LiveChatPaidMessage.js';
export { default as LiveChatPaidSticker } from './classes/livechat/items/LiveChatPaidSticker.js';
export { default as LiveChatPlaceholderItem } from './classes/livechat/items/LiveChatPlaceholderItem.js';
export { default as LiveChatProductItem } from './classes/livechat/items/LiveChatProductItem.js';
export { default as LiveChatRestrictedParticipation } from './classes/livechat/items/LiveChatRestrictedParticipation.js';
export { default as LiveChatTextMessage } from './classes/livechat/items/LiveChatTextMessage.js';
export { default as LiveChatTickerPaidMessageItem } from './classes/livechat/items/LiveChatTickerPaidMessageItem.js';
export { default as LiveChatTickerPaidStickerItem } from './classes/livechat/items/LiveChatTickerPaidStickerItem.js';
export { default as LiveChatTickerSponsorItem } from './classes/livechat/items/LiveChatTickerSponsorItem.js';
export { default as LiveChatViewerEngagementMessage } from './classes/livechat/items/LiveChatViewerEngagementMessage.js';
export { default as PollHeader } from './classes/livechat/items/PollHeader.js';
export { default as LiveChatActionPanel } from './classes/livechat/LiveChatActionPanel.js';
export { default as MarkChatItemAsDeletedAction } from './classes/livechat/MarkChatItemAsDeletedAction.js';
export { default as MarkChatItemsByAuthorAsDeletedAction } from './classes/livechat/MarkChatItemsByAuthorAsDeletedAction.js';
export { default as RemoveBannerForLiveChatCommand } from './classes/livechat/RemoveBannerForLiveChatCommand.js';
export { default as RemoveChatItemAction } from './classes/livechat/RemoveChatItemAction.js';
export { default as RemoveChatItemByAuthorAction } from './classes/livechat/RemoveChatItemByAuthorAction.js';
export { default as ReplaceChatItemAction } from './classes/livechat/ReplaceChatItemAction.js';
export { default as ReplayChatItemAction } from './classes/livechat/ReplayChatItemAction.js';
export { default as ShowLiveChatActionPanelAction } from './classes/livechat/ShowLiveChatActionPanelAction.js';
export { default as ShowLiveChatDialogAction } from './classes/livechat/ShowLiveChatDialogAction.js';
export { default as ShowLiveChatTooltipCommand } from './classes/livechat/ShowLiveChatTooltipCommand.js';
export { default as UpdateDateTextAction } from './classes/livechat/UpdateDateTextAction.js';
export { default as UpdateDescriptionAction } from './classes/livechat/UpdateDescriptionAction.js';
export { default as UpdateLiveChatPollAction } from './classes/livechat/UpdateLiveChatPollAction.js';
export { default as UpdateTitleAction } from './classes/livechat/UpdateTitleAction.js';
export { default as UpdateToggleButtonTextAction } from './classes/livechat/UpdateToggleButtonTextAction.js';
export { default as UpdateViewershipAction } from './classes/livechat/UpdateViewershipAction.js';
export { default as LiveChatAuthorBadge } from './classes/LiveChatAuthorBadge.js';
export { default as LiveChatDialog } from './classes/LiveChatDialog.js';
export { default as LiveChatHeader } from './classes/LiveChatHeader.js';
export { default as LiveChatItemList } from './classes/LiveChatItemList.js';
export { default as LiveChatMessageInput } from './classes/LiveChatMessageInput.js';
export { default as LiveChatParticipant } from './classes/LiveChatParticipant.js';
export { default as LiveChatParticipantsList } from './classes/LiveChatParticipantsList.js';
export { default as LockupMetadataView } from './classes/LockupMetadataView.js';
export { default as LockupView } from './classes/LockupView.js';
export { default as MacroMarkersInfoItem } from './classes/MacroMarkersInfoItem.js';
export { default as MacroMarkersList } from './classes/MacroMarkersList.js';
export { default as MacroMarkersListItem } from './classes/MacroMarkersListItem.js';
export { default as Menu } from './classes/menus/Menu.js';
export { default as MenuNavigationItem } from './classes/menus/MenuNavigationItem.js';
export { default as MenuPopup } from './classes/menus/MenuPopup.js';
export { default as MenuServiceItem } from './classes/menus/MenuServiceItem.js';
export { default as MenuServiceItemDownload } from './classes/menus/MenuServiceItemDownload.js';
export { default as MultiPageMenu } from './classes/menus/MultiPageMenu.js';
export { default as MultiPageMenuNotificationSection } from './classes/menus/MultiPageMenuNotificationSection.js';
export { default as MusicMenuItemDivider } from './classes/menus/MusicMenuItemDivider.js';
export { default as MusicMultiSelectMenu } from './classes/menus/MusicMultiSelectMenu.js';
export { default as MusicMultiSelectMenuItem } from './classes/menus/MusicMultiSelectMenuItem.js';
export { default as SimpleMenuHeader } from './classes/menus/SimpleMenuHeader.js';
export { default as MerchandiseItem } from './classes/MerchandiseItem.js';
export { default as MerchandiseShelf } from './classes/MerchandiseShelf.js';
export { default as Message } from './classes/Message.js';
export { default as MetadataBadge } from './classes/MetadataBadge.js';
export { default as MetadataRow } from './classes/MetadataRow.js';
export { default as MetadataRowContainer } from './classes/MetadataRowContainer.js';
export { default as MetadataRowHeader } from './classes/MetadataRowHeader.js';
export { default as MetadataScreen } from './classes/MetadataScreen.js';
export { default as MicroformatData } from './classes/MicroformatData.js';
export { default as Mix } from './classes/Mix.js';
export { default as ModalWithTitleAndButton } from './classes/ModalWithTitleAndButton.js';
export { default as Movie } from './classes/Movie.js';
export { default as MovingThumbnail } from './classes/MovingThumbnail.js';
export { default as MultiMarkersPlayerBar } from './classes/MultiMarkersPlayerBar.js';
export { default as MusicCardShelf } from './classes/MusicCardShelf.js';
export { default as MusicCardShelfHeaderBasic } from './classes/MusicCardShelfHeaderBasic.js';
export { default as MusicCarouselShelf } from './classes/MusicCarouselShelf.js';
export { default as MusicCarouselShelfBasicHeader } from './classes/MusicCarouselShelfBasicHeader.js';
export { default as MusicDescriptionShelf } from './classes/MusicDescriptionShelf.js';
export { default as MusicDetailHeader } from './classes/MusicDetailHeader.js';
export { default as MusicDownloadStateBadge } from './classes/MusicDownloadStateBadge.js';
export { default as MusicEditablePlaylistDetailHeader } from './classes/MusicEditablePlaylistDetailHeader.js';
export { default as MusicElementHeader } from './classes/MusicElementHeader.js';
export { default as MusicHeader } from './classes/MusicHeader.js';
export { default as MusicImmersiveHeader } from './classes/MusicImmersiveHeader.js';
export { default as MusicInlineBadge } from './classes/MusicInlineBadge.js';
export { default as MusicItemThumbnailOverlay } from './classes/MusicItemThumbnailOverlay.js';
export { default as MusicLargeCardItemCarousel } from './classes/MusicLargeCardItemCarousel.js';
export { default as MusicMultiRowListItem } from './classes/MusicMultiRowListItem.js';
export { default as MusicNavigationButton } from './classes/MusicNavigationButton.js';
export { default as MusicPlayButton } from './classes/MusicPlayButton.js';
export { default as MusicPlaylistEditHeader } from './classes/MusicPlaylistEditHeader.js';
export { default as MusicPlaylistShelf } from './classes/MusicPlaylistShelf.js';
export { default as MusicQueue } from './classes/MusicQueue.js';
export { default as MusicResponsiveHeader } from './classes/MusicResponsiveHeader.js';
export { default as MusicResponsiveListItem } from './classes/MusicResponsiveListItem.js';
export { default as MusicResponsiveListItemFixedColumn } from './classes/MusicResponsiveListItemFixedColumn.js';
export { default as MusicResponsiveListItemFlexColumn } from './classes/MusicResponsiveListItemFlexColumn.js';
export { default as MusicShelf } from './classes/MusicShelf.js';
export { default as MusicSideAlignedItem } from './classes/MusicSideAlignedItem.js';
export { default as MusicSortFilterButton } from './classes/MusicSortFilterButton.js';
export { default as MusicTastebuilderShelf } from './classes/MusicTastebuilderShelf.js';
export { default as MusicTastebuilderShelfThumbnail } from './classes/MusicTastebuilderShelfThumbnail.js';
export { default as MusicThumbnail } from './classes/MusicThumbnail.js';
export { default as MusicTwoRowItem } from './classes/MusicTwoRowItem.js';
export { default as MusicVisualHeader } from './classes/MusicVisualHeader.js';
export { default as NavigationEndpoint } from './classes/NavigationEndpoint.js';
export { default as Notification } from './classes/Notification.js';
export { default as PageHeader } from './classes/PageHeader.js';
export { default as PageHeaderView } from './classes/PageHeaderView.js';
export { default as PageIntroduction } from './classes/PageIntroduction.js';
export { default as PivotButton } from './classes/PivotButton.js';
export { default as PlayerAnnotationsExpanded } from './classes/PlayerAnnotationsExpanded.js';
export { default as PlayerCaptionsTracklist } from './classes/PlayerCaptionsTracklist.js';
export { default as PlayerControlsOverlay } from './classes/PlayerControlsOverlay.js';
export { default as PlayerErrorMessage } from './classes/PlayerErrorMessage.js';
export { default as PlayerLegacyDesktopYpcOffer } from './classes/PlayerLegacyDesktopYpcOffer.js';
export { default as PlayerLegacyDesktopYpcTrailer } from './classes/PlayerLegacyDesktopYpcTrailer.js';
export { default as PlayerLiveStoryboardSpec } from './classes/PlayerLiveStoryboardSpec.js';
export { default as PlayerMicroformat } from './classes/PlayerMicroformat.js';
export { default as PlayerOverflow } from './classes/PlayerOverflow.js';
export { default as PlayerOverlay } from './classes/PlayerOverlay.js';
export { default as PlayerOverlayAutoplay } from './classes/PlayerOverlayAutoplay.js';
export { default as PlayerStoryboardSpec } from './classes/PlayerStoryboardSpec.js';
export { default as Playlist } from './classes/Playlist.js';
export { default as PlaylistCustomThumbnail } from './classes/PlaylistCustomThumbnail.js';
export { default as PlaylistHeader } from './classes/PlaylistHeader.js';
export { default as PlaylistInfoCardContent } from './classes/PlaylistInfoCardContent.js';
export { default as PlaylistMetadata } from './classes/PlaylistMetadata.js';
export { default as PlaylistPanel } from './classes/PlaylistPanel.js';
export { default as PlaylistPanelVideo } from './classes/PlaylistPanelVideo.js';
export { default as PlaylistPanelVideoWrapper } from './classes/PlaylistPanelVideoWrapper.js';
export { default as PlaylistSidebar } from './classes/PlaylistSidebar.js';
export { default as PlaylistSidebarPrimaryInfo } from './classes/PlaylistSidebarPrimaryInfo.js';
export { default as PlaylistSidebarSecondaryInfo } from './classes/PlaylistSidebarSecondaryInfo.js';
export { default as PlaylistVideo } from './classes/PlaylistVideo.js';
export { default as PlaylistVideoList } from './classes/PlaylistVideoList.js';
export { default as PlaylistVideoThumbnail } from './classes/PlaylistVideoThumbnail.js';
export { default as Poll } from './classes/Poll.js';
export { default as Post } from './classes/Post.js';
export { default as PostMultiImage } from './classes/PostMultiImage.js';
export { default as ProductList } from './classes/ProductList.js';
export { default as ProductListHeader } from './classes/ProductListHeader.js';
export { default as ProductListItem } from './classes/ProductListItem.js';
export { default as ProfileColumn } from './classes/ProfileColumn.js';
export { default as ProfileColumnStats } from './classes/ProfileColumnStats.js';
export { default as ProfileColumnStatsEntry } from './classes/ProfileColumnStatsEntry.js';
export { default as ProfileColumnUserInfo } from './classes/ProfileColumnUserInfo.js';
export { default as Quiz } from './classes/Quiz.js';
export { default as RecognitionShelf } from './classes/RecognitionShelf.js';
export { default as ReelItem } from './classes/ReelItem.js';
export { default as ReelPlayerHeader } from './classes/ReelPlayerHeader.js';
export { default as ReelPlayerOverlay } from './classes/ReelPlayerOverlay.js';
export { default as ReelShelf } from './classes/ReelShelf.js';
export { default as RelatedChipCloud } from './classes/RelatedChipCloud.js';
export { default as RichGrid } from './classes/RichGrid.js';
export { default as RichItem } from './classes/RichItem.js';
export { default as RichListHeader } from './classes/RichListHeader.js';
export { default as RichMetadata } from './classes/RichMetadata.js';
export { default as RichMetadataRow } from './classes/RichMetadataRow.js';
export { default as RichSection } from './classes/RichSection.js';
export { default as RichShelf } from './classes/RichShelf.js';
export { default as SearchBox } from './classes/SearchBox.js';
export { default as SearchFilter } from './classes/SearchFilter.js';
export { default as SearchFilterGroup } from './classes/SearchFilterGroup.js';
export { default as SearchFilterOptionsDialog } from './classes/SearchFilterOptionsDialog.js';
export { default as SearchHeader } from './classes/SearchHeader.js';
export { default as SearchRefinementCard } from './classes/SearchRefinementCard.js';
export { default as SearchSubMenu } from './classes/SearchSubMenu.js';
export { default as SearchSuggestion } from './classes/SearchSuggestion.js';
export { default as SearchSuggestionsSection } from './classes/SearchSuggestionsSection.js';
export { default as SecondarySearchContainer } from './classes/SecondarySearchContainer.js';
export { default as SectionList } from './classes/SectionList.js';
export { default as SegmentedLikeDislikeButton } from './classes/SegmentedLikeDislikeButton.js';
export { default as SegmentedLikeDislikeButtonView } from './classes/SegmentedLikeDislikeButtonView.js';
export { default as SettingBoolean } from './classes/SettingBoolean.js';
export { default as SettingsCheckbox } from './classes/SettingsCheckbox.js';
export { default as SettingsOptions } from './classes/SettingsOptions.js';
export { default as SettingsSidebar } from './classes/SettingsSidebar.js';
export { default as SettingsSwitch } from './classes/SettingsSwitch.js';
export { default as SharedPost } from './classes/SharedPost.js';
export { default as Shelf } from './classes/Shelf.js';
export { default as ShowCustomThumbnail } from './classes/ShowCustomThumbnail.js';
export { default as ShowingResultsFor } from './classes/ShowingResultsFor.js';
export { default as SimpleCardContent } from './classes/SimpleCardContent.js';
export { default as SimpleCardTeaser } from './classes/SimpleCardTeaser.js';
export { default as SimpleTextSection } from './classes/SimpleTextSection.js';
export { default as SingleActionEmergencySupport } from './classes/SingleActionEmergencySupport.js';
export { default as SingleColumnBrowseResults } from './classes/SingleColumnBrowseResults.js';
export { default as SingleColumnMusicWatchNextResults } from './classes/SingleColumnMusicWatchNextResults.js';
export { default as SingleHeroImage } from './classes/SingleHeroImage.js';
export { default as SlimOwner } from './classes/SlimOwner.js';
export { default as SlimVideoMetadata } from './classes/SlimVideoMetadata.js';
export { default as SortFilterHeader } from './classes/SortFilterHeader.js';
export { default as SortFilterSubMenu } from './classes/SortFilterSubMenu.js';
export { default as StructuredDescriptionContent } from './classes/StructuredDescriptionContent.js';
export { default as StructuredDescriptionPlaylistLockup } from './classes/StructuredDescriptionPlaylistLockup.js';
export { default as SubFeedOption } from './classes/SubFeedOption.js';
export { default as SubFeedSelector } from './classes/SubFeedSelector.js';
export { default as SubscribeButton } from './classes/SubscribeButton.js';
export { default as SubscriptionNotificationToggleButton } from './classes/SubscriptionNotificationToggleButton.js';
export { default as Tab } from './classes/Tab.js';
export { default as Tabbed } from './classes/Tabbed.js';
export { default as TabbedSearchResults } from './classes/TabbedSearchResults.js';
export { default as TextHeader } from './classes/TextHeader.js';
export { default as ThumbnailBadgeView } from './classes/ThumbnailBadgeView.js';
export { default as ThumbnailHoverOverlayView } from './classes/ThumbnailHoverOverlayView.js';
export { default as ThumbnailLandscapePortrait } from './classes/ThumbnailLandscapePortrait.js';
export { default as ThumbnailOverlayBadgeView } from './classes/ThumbnailOverlayBadgeView.js';
export { default as ThumbnailOverlayBottomPanel } from './classes/ThumbnailOverlayBottomPanel.js';
export { default as ThumbnailOverlayEndorsement } from './classes/ThumbnailOverlayEndorsement.js';
export { default as ThumbnailOverlayHoverText } from './classes/ThumbnailOverlayHoverText.js';
export { default as ThumbnailOverlayInlineUnplayable } from './classes/ThumbnailOverlayInlineUnplayable.js';
export { default as ThumbnailOverlayLoadingPreview } from './classes/ThumbnailOverlayLoadingPreview.js';
export { default as ThumbnailOverlayNowPlaying } from './classes/ThumbnailOverlayNowPlaying.js';
export { default as ThumbnailOverlayPinking } from './classes/ThumbnailOverlayPinking.js';
export { default as ThumbnailOverlayPlaybackStatus } from './classes/ThumbnailOverlayPlaybackStatus.js';
export { default as ThumbnailOverlayResumePlayback } from './classes/ThumbnailOverlayResumePlayback.js';
export { default as ThumbnailOverlaySidePanel } from './classes/ThumbnailOverlaySidePanel.js';
export { default as ThumbnailOverlayTimeStatus } from './classes/ThumbnailOverlayTimeStatus.js';
export { default as ThumbnailOverlayToggleButton } from './classes/ThumbnailOverlayToggleButton.js';
export { default as ThumbnailView } from './classes/ThumbnailView.js';
export { default as TimedMarkerDecoration } from './classes/TimedMarkerDecoration.js';
export { default as TitleAndButtonListHeader } from './classes/TitleAndButtonListHeader.js';
export { default as ToggleButton } from './classes/ToggleButton.js';
export { default as ToggleButtonView } from './classes/ToggleButtonView.js';
export { default as ToggleMenuServiceItem } from './classes/ToggleMenuServiceItem.js';
export { default as Tooltip } from './classes/Tooltip.js';
export { default as TopicChannelDetails } from './classes/TopicChannelDetails.js';
export { default as Transcript } from './classes/Transcript.js';
export { default as TranscriptFooter } from './classes/TranscriptFooter.js';
export { default as TranscriptSearchBox } from './classes/TranscriptSearchBox.js';
export { default as TranscriptSearchPanel } from './classes/TranscriptSearchPanel.js';
export { default as TranscriptSectionHeader } from './classes/TranscriptSectionHeader.js';
export { default as TranscriptSegment } from './classes/TranscriptSegment.js';
export { default as TranscriptSegmentList } from './classes/TranscriptSegmentList.js';
export { default as TwoColumnBrowseResults } from './classes/TwoColumnBrowseResults.js';
export { default as TwoColumnSearchResults } from './classes/TwoColumnSearchResults.js';
export { default as TwoColumnWatchNextResults } from './classes/TwoColumnWatchNextResults.js';
export { default as UniversalWatchCard } from './classes/UniversalWatchCard.js';
export { default as UploadTimeFactoid } from './classes/UploadTimeFactoid.js';
export { default as UpsellDialog } from './classes/UpsellDialog.js';
export { default as VerticalList } from './classes/VerticalList.js';
export { default as VerticalWatchCardList } from './classes/VerticalWatchCardList.js';
export { default as Video } from './classes/Video.js';
export { default as VideoAttributesSectionView } from './classes/VideoAttributesSectionView.js';
export { default as VideoAttributeView } from './classes/VideoAttributeView.js';
export { default as VideoCard } from './classes/VideoCard.js';
export { default as VideoDescriptionCourseSection } from './classes/VideoDescriptionCourseSection.js';
export { default as VideoDescriptionHeader } from './classes/VideoDescriptionHeader.js';
export { default as VideoDescriptionInfocardsSection } from './classes/VideoDescriptionInfocardsSection.js';
export { default as VideoDescriptionMusicSection } from './classes/VideoDescriptionMusicSection.js';
export { default as VideoDescriptionTranscriptSection } from './classes/VideoDescriptionTranscriptSection.js';
export { default as VideoInfoCardContent } from './classes/VideoInfoCardContent.js';
export { default as VideoOwner } from './classes/VideoOwner.js';
export { default as VideoPrimaryInfo } from './classes/VideoPrimaryInfo.js';
export { default as VideoSecondaryInfo } from './classes/VideoSecondaryInfo.js';
export { default as ViewCountFactoid } from './classes/ViewCountFactoid.js';
export { default as WatchCardCompactVideo } from './classes/WatchCardCompactVideo.js';
export { default as WatchCardHeroVideo } from './classes/WatchCardHeroVideo.js';
export { default as WatchCardRichHeader } from './classes/WatchCardRichHeader.js';
export { default as WatchCardSectionSequence } from './classes/WatchCardSectionSequence.js';
export { default as WatchNextEndScreen } from './classes/WatchNextEndScreen.js';
export { default as WatchNextTabbedResults } from './classes/WatchNextTabbedResults.js';
export { default as YpcTrailer } from './classes/YpcTrailer.js';
export { default as AnchoredSection } from './classes/ytkids/AnchoredSection.js';
export { default as KidsBlocklistPicker } from './classes/ytkids/KidsBlocklistPicker.js';
export { default as KidsBlocklistPickerItem } from './classes/ytkids/KidsBlocklistPickerItem.js';
export { default as KidsCategoriesHeader } from './classes/ytkids/KidsCategoriesHeader.js';
export { default as KidsCategoryTab } from './classes/ytkids/KidsCategoryTab.js';
export { default as KidsHomeScreen } from './classes/ytkids/KidsHomeScreen.js';

LuanRT/YouTube.js/blob/main/src/parser/parser.ts:

import * as YTNodes from './nodes.js';
import { InnertubeError, ParsingError, Platform } from '../utils/Utils.js';
import { Memo, observe, SuperParsedResult } from './helpers.js';
import { camelToSnake, generateRuntimeClass, generateTypescriptClass } from './generator.js';
import { Log } from '../utils/index.js';

import {
  Continuation, ItemSectionContinuation, SectionListContinuation,
  LiveChatContinuation, MusicPlaylistShelfContinuation, MusicShelfContinuation,
  GridContinuation, PlaylistPanelContinuation, NavigateAction, ShowMiniplayerCommand,
  ReloadContinuationItemsCommand, ContinuationCommand
} from './continuations.js';

import AudioOnlyPlayability from './classes/AudioOnlyPlayability.js';
import CardCollection from './classes/CardCollection.js';
import Endscreen from './classes/Endscreen.js';
import PlayerAnnotationsExpanded from './classes/PlayerAnnotationsExpanded.js';
import PlayerCaptionsTracklist from './classes/PlayerCaptionsTracklist.js';
import PlayerLiveStoryboardSpec from './classes/PlayerLiveStoryboardSpec.js';
import PlayerStoryboardSpec from './classes/PlayerStoryboardSpec.js';
import Alert from './classes/Alert.js';
import AlertWithButton from './classes/AlertWithButton.js';
import EngagementPanelSectionList from './classes/EngagementPanelSectionList.js';
import MusicMultiSelectMenuItem from './classes/menus/MusicMultiSelectMenuItem.js';
import Format from './classes/misc/Format.js';
import VideoDetails from './classes/misc/VideoDetails.js';
import NavigationEndpoint from './classes/NavigationEndpoint.js';
import CommentView from './classes/comments/CommentView.js';
import MusicThumbnail from './classes/MusicThumbnail.js';

import type { KeyInfo } from './generator.js';
import type { ObservedArray, YTNodeConstructor, YTNode } from './helpers.js';
import type { IParsedResponse, IRawResponse, RawData, RawNode } from './types/index.js';

const TAG = 'Parser';

export type ParserError = {
  classname: string,
} & ({
  error_type: 'typecheck',
  classdata: RawNode,
  expected: string | string[]
} | {
  error_type: 'parse',
  classdata: RawNode,
  error: unknown
} | {
  error_type: 'mutation_data_missing',
  classname: string
} | {
  error_type: 'mutation_data_invalid',
  total: number,
  failed: number,
  titles: string[]
} | {
  error_type: 'class_not_found',
  key_info: KeyInfo,
} | {
  error_type: 'class_changed',
  key_info: KeyInfo,
  changed_keys: KeyInfo
});

export type ParserErrorHandler = (error: ParserError) => void;

const IGNORED_LIST = new Set([
  'AdSlot',
  'DisplayAd',
  'SearchPyv',
  'MealbarPromo',
  'PrimetimePromo',
  'BackgroundPromo',
  'PromotedSparklesWeb',
  'RunAttestationCommand',
  'CompactPromotedVideo',
  'BrandVideoShelf',
  'BrandVideoSingleton',
  'StatementBanner',
  'GuideSigninPromo',
  'AdsEngagementPanelContent',
  'MiniGameCardView'
]);

const RUNTIME_NODES = new Map<string, YTNodeConstructor>(Object.entries(YTNodes));

const DYNAMIC_NODES = new Map<string, YTNodeConstructor>();

let MEMO: Memo | null = null;

let ERROR_HANDLER: ParserErrorHandler = ({ classname, ...context }: ParserError) => {
  switch (context.error_type) {
    case 'parse':
      if (context.error instanceof Error) {
        Log.warn(TAG,
          new InnertubeError(
            `Something went wrong at ${classname}!\n` +
            `This is a bug, please report it at ${Platform.shim.info.bugs_url}`, {
              stack: context.error.stack,
              classdata: JSON.stringify(context.classdata, null, 2)
            }
          )
        );
      }
      break;
    case 'typecheck':
      Log.warn(TAG,
        new ParsingError(
          `Type mismatch, got ${classname} expected ${Array.isArray(context.expected) ? context.expected.join(' | ') : context.expected}.`,
          context.classdata
        )
      );
      break;
    case 'mutation_data_missing':
      Log.warn(TAG,
        new InnertubeError(
          `Mutation data required for processing ${classname}, but none found.\n` +
          `This is a bug, please report it at ${Platform.shim.info.bugs_url}`
        )
      );
      break;
    case 'mutation_data_invalid':
      Log.warn(TAG,
        new InnertubeError(
          `Mutation data missing or invalid for ${context.failed} out of ${context.total} MusicMultiSelectMenuItems. ` +
          `The titles of the failed items are: ${context.titles.join(', ')}.\n` +
          `This is a bug, please report it at ${Platform.shim.info.bugs_url}`
        )
      );
      break;
    case 'class_not_found':
      Log.warn(TAG,
        new InnertubeError(
          `${classname} not found!\n` +
          `This is a bug, want to help us fix it? Follow the instructions at ${Platform.shim.info.repo_url}/blob/main/docs/updating-the-parser.md or report it at ${Platform.shim.info.bugs_url}!\n` +
          `Introspected and JIT generated this class in the meantime:\n${generateTypescriptClass(classname, context.key_info)}`
        )
      );
      break;
    case 'class_changed':
      Log.warn(TAG,
        `${classname} changed!\n` +
        `The following keys where altered: ${context.changed_keys.map(([ key ]) => camelToSnake(key)).join(', ')}\n` +
        `The class has changed to:\n${generateTypescriptClass(classname, context.key_info)}`
      );
      break;
    default:
      Log.warn(TAG,
        'Unreachable code reached at ParserErrorHandler'
      );
      break;
  }
};

export function setParserErrorHandler(handler: ParserErrorHandler) {
  ERROR_HANDLER = handler;
}

function _clearMemo() {
  MEMO = null;
}

function _createMemo() {
  MEMO = new Memo();
}

function _addToMemo(classname: string, result: YTNode) {
  if (!MEMO)
    return;

  const list = MEMO.get(classname);
  if (!list)
    return MEMO.set(classname, [ result ]);

  list.push(result);
}

function _getMemo() {
  if (!MEMO)
    throw new Error('Parser#getMemo() called before Parser#createMemo()');
  return MEMO;
}

export function shouldIgnore(classname: string) {
  return IGNORED_LIST.has(classname);
}

export function sanitizeClassName(input: string) {
  return (input.charAt(0).toUpperCase() + input.slice(1))
    .replace(/Renderer|Model/g, '')
    .replace(/Radio/g, 'Mix').trim();
}

export function getParserByName(classname: string) {
  const ParserConstructor = RUNTIME_NODES.get(classname);

  if (!ParserConstructor) {
    const error = new Error(`Module not found: ${classname}`);
    (error as any).code = 'MODULE_NOT_FOUND';
    throw error;
  }

  return ParserConstructor;
}

export function hasParser(classname: string) {
  return RUNTIME_NODES.has(classname);
}

export function addRuntimeParser(classname: string, ParserConstructor: YTNodeConstructor) {
  RUNTIME_NODES.set(classname, ParserConstructor);
  DYNAMIC_NODES.set(classname, ParserConstructor);
}

export function getDynamicParsers() {
  return Object.fromEntries(DYNAMIC_NODES);
}

/**
 * Parses given InnerTube response.
 * @param data - Raw data.
 */
export function parseResponse<T extends IParsedResponse = IParsedResponse>(data: IRawResponse): T {
  const parsed_data = {} as T;

  _createMemo();
  const contents = parse(data.contents);
  const contents_memo = _getMemo();
  if (contents) {
    parsed_data.contents = contents;
    parsed_data.contents_memo = contents_memo;
  }
  _clearMemo();

  _createMemo();
  const on_response_received_actions = data.onResponseReceivedActions ? parseRR(data.onResponseReceivedActions) : null;
  const on_response_received_actions_memo = _getMemo();
  if (on_response_received_actions) {
    parsed_data.on_response_received_actions = on_response_received_actions;
    parsed_data.on_response_received_actions_memo = on_response_received_actions_memo;
  }
  _clearMemo();

  _createMemo();
  const on_response_received_endpoints = data.onResponseReceivedEndpoints ? parseRR(data.onResponseReceivedEndpoints) : null;
  const on_response_received_endpoints_memo = _getMemo();
  if (on_response_received_endpoints) {
    parsed_data.on_response_received_endpoints = on_response_received_endpoints;
    parsed_data.on_response_received_endpoints_memo = on_response_received_endpoints_memo;
  }
  _clearMemo();

  _createMemo();
  const on_response_received_commands = data.onResponseReceivedCommands ? parseRR(data.onResponseReceivedCommands) : null;
  const on_response_received_commands_memo = _getMemo();
  if (on_response_received_commands) {
    parsed_data.on_response_received_commands = on_response_received_commands;
    parsed_data.on_response_received_commands_memo = on_response_received_commands_memo;
  }
  _clearMemo();

  _createMemo();
  const continuation_contents = data.continuationContents ? parseLC(data.continuationContents) : null;
  const continuation_contents_memo = _getMemo();
  if (continuation_contents) {
    parsed_data.continuation_contents = continuation_contents;
    parsed_data.continuation_contents_memo = continuation_contents_memo;
  }
  _clearMemo();

  _createMemo();
  const actions = data.actions ? parseActions(data.actions) : null;
  const actions_memo = _getMemo();
  if (actions) {
    parsed_data.actions = actions;
    parsed_data.actions_memo = actions_memo;
  }
  _clearMemo();

  _createMemo();
  const live_chat_item_context_menu_supported_renderers = data.liveChatItemContextMenuSupportedRenderers ? parseItem(data.liveChatItemContextMenuSupportedRenderers) : null;
  const live_chat_item_context_menu_supported_renderers_memo = _getMemo();
  if (live_chat_item_context_menu_supported_renderers) {
    parsed_data.live_chat_item_context_menu_supported_renderers = live_chat_item_context_menu_supported_renderers;
    parsed_data.live_chat_item_context_menu_supported_renderers_memo = live_chat_item_context_menu_supported_renderers_memo;
  }
  _clearMemo();

  _createMemo();
  const header = data.header ? parse(data.header) : null;
  const header_memo = _getMemo();
  if (header) {
    parsed_data.header = header;
    parsed_data.header_memo = header_memo;
  }
  _clearMemo();

  _createMemo();
  const sidebar = data.sidebar ? parseItem(data.sidebar) : null;
  const sidebar_memo = _getMemo();
  if (sidebar) {
    parsed_data.sidebar = sidebar;
    parsed_data.sidebar_memo = sidebar_memo;
  }
  _clearMemo();

  _createMemo();
  const items = parse(data.items);
  if (items) {
    parsed_data.items = items;
    parsed_data.items_memo = _getMemo();
  }
  _clearMemo();

  applyMutations(contents_memo, data.frameworkUpdates?.entityBatchUpdate?.mutations);

  if (on_response_received_endpoints_memo) {
    applyCommentsMutations(on_response_received_endpoints_memo, data.frameworkUpdates?.entityBatchUpdate?.mutations);
  }

  const continuation = data.continuation ? parseC(data.continuation) : null;
  if (continuation) {
    parsed_data.continuation = continuation;
  }

  const continuation_endpoint = data.continuationEndpoint ? parseLC(data.continuationEndpoint) : null;
  if (continuation_endpoint) {
    parsed_data.continuation_endpoint = continuation_endpoint;
  }

  const metadata = parse(data.metadata);
  if (metadata) {
    parsed_data.metadata = metadata;
  }

  const microformat = parseItem(data.microformat);
  if (microformat) {
    parsed_data.microformat = microformat;
  }

  const overlay = parseItem(data.overlay);
  if (overlay) {
    parsed_data.overlay = overlay;
  }

  const alerts = parseArray(data.alerts, [ Alert, AlertWithButton ]);
  if (alerts.length) {
    parsed_data.alerts = alerts;
  }

  const refinements = data.refinements;
  if (refinements) {
    parsed_data.refinements = refinements;
  }

  const estimated_results = data.estimatedResults ? parseInt(data.estimatedResults) : null;
  if (estimated_results) {
    parsed_data.estimated_results = estimated_results;
  }

  const player_overlays = parse(data.playerOverlays);
  if (player_overlays) {
    parsed_data.player_overlays = player_overlays;
  }

  const background = parseItem(data.background, MusicThumbnail);
  if (background) {
    parsed_data.background = background;
  }

  const playback_tracking = data.playbackTracking ? {
    videostats_watchtime_url: data.playbackTracking.videostatsWatchtimeUrl.baseUrl,
    videostats_playback_url: data.playbackTracking.videostatsPlaybackUrl.baseUrl
  } : null;

  if (playback_tracking) {
    parsed_data.playback_tracking = playback_tracking;
  }

  const playability_status = data.playabilityStatus ? {
    status: data.playabilityStatus.status,
    reason: data.playabilityStatus.reason || '',
    embeddable: !!data.playabilityStatus.playableInEmbed || false,
    audio_only_playablility: parseItem(data.playabilityStatus.audioOnlyPlayability, AudioOnlyPlayability),
    error_screen: parseItem(data.playabilityStatus.errorScreen)
  } : null;

  if (playability_status) {
    parsed_data.playability_status = playability_status;
  }

  if (data.streamingData) {
    // Currently each response with streaming data only has two n param values
    // One for the adaptive formats and another for the combined formats
    // As they are the same for a response, we only need to decipher them once
    // For all futher deciphering calls on formats from that response, we can use the cached output, given the same input n param
    const this_response_nsig_cache = new Map<string, string>();

    const streaming_data = {
      expires: new Date(Date.now() + parseInt(data.streamingData.expiresInSeconds) * 1000),
      formats: parseFormats(data.streamingData.formats, this_response_nsig_cache),
      adaptive_formats: parseFormats(data.streamingData.adaptiveFormats, this_response_nsig_cache),
      dash_manifest_url: data.streamingData.dashManifestUrl || null,
      hls_manifest_url: data.streamingData.hlsManifestUrl || null
    };

    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;
  }

  const endpoint = data.endpoint ? new NavigationEndpoint(data.endpoint) : null;
  if (endpoint) {
    parsed_data.endpoint = endpoint;
  }

  const captions = parseItem(data.captions, PlayerCaptionsTracklist);
  if (captions) {
    parsed_data.captions = captions;
  }

  const video_details = data.videoDetails ? new VideoDetails(data.videoDetails) : null;
  if (video_details) {
    parsed_data.video_details = video_details;
  }

  const annotations = parseArray(data.annotations, PlayerAnnotationsExpanded);
  if (annotations.length) {
    parsed_data.annotations = annotations;
  }

  const storyboards = parseItem(data.storyboards, [ PlayerStoryboardSpec, PlayerLiveStoryboardSpec ]);
  if (storyboards) {
    parsed_data.storyboards = storyboards;
  }

  const endscreen = parseItem(data.endscreen, Endscreen);
  if (endscreen) {
    parsed_data.endscreen = endscreen;
  }

  const cards = parseItem(data.cards, CardCollection);
  if (cards) {
    parsed_data.cards = cards;
  }

  const engagement_panels = parseArray(data.engagementPanels, EngagementPanelSectionList);
  if (engagement_panels.length) {
    parsed_data.engagement_panels = engagement_panels;
  }

  if (data.playerResponse) {
    const player_response = parseResponse(data.playerResponse);
    parsed_data.player_response = player_response;
  }

  if (data.watchNextResponse) {
    const watch_next_response = parseResponse(data.watchNextResponse);
    parsed_data.watch_next_response = watch_next_response;
  }

  if (data.cpnInfo) {
    const cpn_info = {
      cpn: data.cpnInfo.cpn,
      cpn_source: data.cpnInfo.cpnSource
    };

    parsed_data.cpn_info = cpn_info;
  }

  if (data.entries) {
    parsed_data.entries = data.entries.map((entry) => new NavigationEndpoint(entry));
  }

  return parsed_data;
}

/**
 * Parses a single item.
 * @param data - The data to parse.
 * @param validTypes - YTNode types that are allowed to be parsed.
 */
export function parseItem<T extends YTNode, K extends YTNodeConstructor<T>[]>(data: RawNode | undefined, validTypes: K): InstanceType<K[number]> | null;
export function parseItem<T extends YTNode>(data: RawNode | undefined, validTypes: YTNodeConstructor<T>): T | null;
export function parseItem(data?: RawNode): YTNode;
export function parseItem(data?: RawNode, validTypes?: YTNodeConstructor | YTNodeConstructor[]) {
  if (!data) return null;

  const keys = Object.keys(data);

  if (!keys.length)
    return null;

  const classname = sanitizeClassName(keys[0]);

  if (!shouldIgnore(classname)) {
    try {
      const has_target_class = hasParser(classname);

      const TargetClass = has_target_class ?
        getParserByName(classname) :
        generateRuntimeClass(classname, data[keys[0]], ERROR_HANDLER);

      if (validTypes) {
        if (Array.isArray(validTypes)) {
          if (!validTypes.some((type) => type.type === TargetClass.type)) {
            ERROR_HANDLER({
              classdata: data[keys[0]],
              classname,
              error_type: 'typecheck',
              expected: validTypes.map((type) => type.type)
            });
            return null;
          }
        } else if (TargetClass.type !== validTypes.type) {
          ERROR_HANDLER({
            classdata: data[keys[0]],
            classname,
            error_type: 'typecheck',
            expected: validTypes.type
          });
          return null;
        }
      }

      const result = new TargetClass(data[keys[0]]);
      _addToMemo(classname, result);

      return result;
    } catch (err) {
      ERROR_HANDLER({
        classname,
        classdata: data[keys[0]],
        error: err,
        error_type: 'parse'
      });
      return null;
    }
  }

  return null;
}

/**
 * Parses an array of items.
 * @param data - The data to parse.
 * @param validTypes - YTNode types that are allowed to be parsed.
 */
export function parseArray<T extends YTNode, K extends YTNodeConstructor<T>[]>(data: RawNode[] | undefined, validTypes: K): ObservedArray<InstanceType<K[number]>>;
export function parseArray<T extends YTNode = YTNode>(data: RawNode[] | undefined, validType: YTNodeConstructor<T>): ObservedArray<T>;
export function parseArray(data: RawNode[] | undefined): ObservedArray<YTNode>;
export function parseArray(data?: RawNode[], validTypes?: YTNodeConstructor | YTNodeConstructor[]) {
  if (Array.isArray(data)) {
    const results: YTNode[] = [];

    for (const item of data) {
      const result = parseItem(item, validTypes as YTNodeConstructor);
      if (result) {
        results.push(result);
      }
    }

    return observe(results);
  } else if (!data) {
    return observe([] as YTNode[]);
  }
  throw new ParsingError('Expected array but got a single item');
}

/**
 * Parses an item or an array of items.
 * @param data - The data to parse.
 * @param requireArray - Whether the data should be parsed as an array.
 * @param validTypes - YTNode types that are allowed to be parsed.
 */
export function parse<T extends YTNode, K extends YTNodeConstructor<T>[]>(data: RawData, requireArray: true, validTypes?: K): ObservedArray<InstanceType<K[number]>> | null;
export function parse<T extends YTNode, K extends YTNodeConstructor<T>>(data: RawData, requireArray: true, validTypes?: K): ObservedArray<InstanceType<K>> | null;
export function parse<T extends YTNode = YTNode>(data?: RawData, requireArray?: false | undefined, validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[]): SuperParsedResult<T>;
export function parse<T extends YTNode = YTNode>(data?: RawData, requireArray?: boolean, validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[]) {
  if (!data) return null;

  if (Array.isArray(data)) {
    const results: T[] = [];

    for (const item of data) {
      const result = parseItem(item, validTypes as YTNodeConstructor<T>);
      if (result) {
        results.push(result);
      }
    }

    const res = observe(results);

    return requireArray ? res : new SuperParsedResult(res);
  } else if (requireArray) {
    throw new ParsingError('Expected array but got a single item');
  }

  return new SuperParsedResult(parseItem(data, validTypes as YTNodeConstructor<T>));
}

export function parseC(data: RawNode) {
  if (data.timedContinuationData)
    return new Continuation({ continuation: data.timedContinuationData, type: 'timed' });
  return null;
}

export function parseLC(data: RawNode) {
  if (data.itemSectionContinuation)
    return new ItemSectionContinuation(data.itemSectionContinuation);
  if (data.sectionListContinuation)
    return new SectionListContinuation(data.sectionListContinuation);
  if (data.liveChatContinuation)
    return new LiveChatContinuation(data.liveChatContinuation);
  if (data.musicPlaylistShelfContinuation)
    return new MusicPlaylistShelfContinuation(data.musicPlaylistShelfContinuation);
  if (data.musicShelfContinuation)
    return new MusicShelfContinuation(data.musicShelfContinuation);
  if (data.gridContinuation)
    return new GridContinuation(data.gridContinuation);
  if (data.playlistPanelContinuation)
    return new PlaylistPanelContinuation(data.playlistPanelContinuation);
  if (data.continuationCommand)
    return new ContinuationCommand(data.continuationCommand);

  return null;
}

export function parseRR(actions: RawNode[]) {
  return observe(actions.map((action: any) => {
    if (action.navigateAction)
      return new NavigateAction(action.navigateAction);
    if (action.showMiniplayerCommand)
      return new ShowMiniplayerCommand(action.showMiniplayerCommand);
    if (action.reloadContinuationItemsCommand)
      return new ReloadContinuationItemsCommand(action.reloadContinuationItemsCommand);
    if (action.appendContinuationItemsAction)
      return new YTNodes.AppendContinuationItemsAction(action.appendContinuationItemsAction);
  }).filter((item) => item) as (ReloadContinuationItemsCommand | YTNodes.AppendContinuationItemsAction)[]);
}

export function parseActions(data: RawData) {
  if (Array.isArray(data)) {
    return parse(data.map((action) => {
      delete action.clickTrackingParams;
      return action;
    }));
  }
  return new SuperParsedResult(parseItem(data));
}

export function parseFormats(formats: RawNode[], this_response_nsig_cache: Map<string, string>): Format[] {
  return formats?.map((format) => new Format(format, this_response_nsig_cache)) || [];
}

export function applyMutations(memo: Memo, mutations: RawNode[]) {
  // Apply mutations to MusicMultiSelectMenuItems
  const music_multi_select_menu_items = memo.getType(MusicMultiSelectMenuItem);

  if (music_multi_select_menu_items.length > 0 && !mutations) {
    ERROR_HANDLER({
      error_type: 'mutation_data_missing',
      classname: 'MusicMultiSelectMenuItem'
    });
  } else {
    const missing_or_invalid_mutations = [];

    for (const menu_item of music_multi_select_menu_items) {
      const mutation = mutations
        .find((mutation) => mutation.payload?.musicFormBooleanChoice?.id === menu_item.form_item_entity_key);

      const choice = mutation?.payload.musicFormBooleanChoice;

      if (choice?.selected !== undefined && choice?.opaqueToken) {
        menu_item.selected = choice.selected;
      } else {
        missing_or_invalid_mutations.push(`'${menu_item.title}'`);
      }
    }
    if (missing_or_invalid_mutations.length > 0) {
      ERROR_HANDLER({
        error_type: 'mutation_data_invalid',
        classname: 'MusicMultiSelectMenuItem',
        total: music_multi_select_menu_items.length,
        failed: missing_or_invalid_mutations.length,
        titles: missing_or_invalid_mutations
      });
    }
  }
}

export function applyCommentsMutations(memo: Memo, mutations: RawNode[]) {
  const comment_view_items = memo.getType(CommentView);

  if (comment_view_items.length > 0) {
    if (!mutations) {
      ERROR_HANDLER({
        error_type: 'mutation_data_missing',
        classname: 'CommentView'
      });
    }

    for (const comment_view of comment_view_items) {
      const comment_mutation = mutations
        .find((mutation) => mutation.payload?.commentEntityPayload?.key === comment_view.keys.comment)
        ?.payload?.commentEntityPayload;

      const toolbar_state_mutation = mutations
        .find((mutation) => mutation.payload?.engagementToolbarStateEntityPayload?.key === comment_view.keys.toolbar_state)
        ?.payload?.engagementToolbarStateEntityPayload;

      const engagement_toolbar = mutations.find((mutation) => mutation.entityKey === comment_view.keys.toolbar_surface)
        ?.payload?.engagementToolbarSurfaceEntityPayload;

      comment_view.applyMutations(comment_mutation, toolbar_state_mutation, engagement_toolbar);
    }
  }
}

LuanRT/YouTube.js/blob/main/src/parser/types/ParsedResponse.ts:

import type { Memo, ObservedArray, SuperParsedResult, YTNode } from '../helpers.js';
import type {
  ReloadContinuationItemsCommand, Continuation, GridContinuation,
  ItemSectionContinuation, LiveChatContinuation, MusicPlaylistShelfContinuation, MusicShelfContinuation,
  PlaylistPanelContinuation, SectionListContinuation, ContinuationCommand,
  CpnSource
} from '../index.js';
import type PlayerCaptionsTracklist from '../classes/PlayerCaptionsTracklist.js';
import type CardCollection from '../classes/CardCollection.js';
import type Endscreen from '../classes/Endscreen.js';
import type AudioOnlyPlayability from '../classes/AudioOnlyPlayability.js';
import type Format from '../classes/misc/Format.js';
import type PlayerLiveStoryboardSpec from '../classes/PlayerLiveStoryboardSpec.js';
import type PlayerStoryboardSpec from '../classes/PlayerStoryboardSpec.js';
import type VideoDetails from '../classes/misc/VideoDetails.js';
import type Alert from '../classes/Alert.js';
import type AlertWithButton from '../classes/AlertWithButton.js';
import type NavigationEndpoint from '../classes/NavigationEndpoint.js';
import type PlayerAnnotationsExpanded from '../classes/PlayerAnnotationsExpanded.js';
import type EngagementPanelSectionList from '../classes/EngagementPanelSectionList.js';
import type { AppendContinuationItemsAction, MusicThumbnail } from '../nodes.js';

export interface IParsedResponse {
  background?: MusicThumbnail;
  actions?: SuperParsedResult<YTNode>;
  actions_memo?: Memo;
  contents?: SuperParsedResult<YTNode>;
  contents_memo?: Memo;
  header?: SuperParsedResult<YTNode>;
  header_memo?: Memo;
  sidebar?: YTNode;
  sidebar_memo?: Memo;
  live_chat_item_context_menu_supported_renderers?: YTNode;
  live_chat_item_context_menu_supported_renderers_memo?: Memo;
  items_memo?: Memo;
  on_response_received_actions?: ObservedArray<ReloadContinuationItemsCommand | AppendContinuationItemsAction>;
  on_response_received_actions_memo?: Memo;
  on_response_received_endpoints?: ObservedArray<ReloadContinuationItemsCommand | AppendContinuationItemsAction>;
  on_response_received_endpoints_memo?: Memo;
  on_response_received_commands?: ObservedArray<ReloadContinuationItemsCommand | AppendContinuationItemsAction>;
  on_response_received_commands_memo?: Memo;
  continuation?: Continuation;
  continuation_contents?: ItemSectionContinuation | SectionListContinuation | LiveChatContinuation | MusicPlaylistShelfContinuation |
  MusicShelfContinuation | GridContinuation | PlaylistPanelContinuation | ContinuationCommand;
  continuation_contents_memo?: Memo;
  metadata?: SuperParsedResult<YTNode>;
  microformat?: YTNode;
  overlay?: YTNode;
  alerts?: ObservedArray<Alert | AlertWithButton>;
  refinements?: string[];
  estimated_results?: number;
  player_overlays?: SuperParsedResult<YTNode>;
  playback_tracking?: IPlaybackTracking;
  playability_status?: IPlayabilityStatus;
  streaming_data?: IStreamingData;
  player_config?: IPlayerConfig;
  current_video_endpoint?: NavigationEndpoint;
  endpoint?: NavigationEndpoint;
  captions?: PlayerCaptionsTracklist;
  video_details?: VideoDetails;
  annotations?: ObservedArray<PlayerAnnotationsExpanded>;
  storyboards?: PlayerStoryboardSpec | PlayerLiveStoryboardSpec;
  endscreen?: Endscreen;
  cards?: CardCollection;
  cpn_info?: {
    cpn: string;
    cpn_source: CpnSource;
  },
  engagement_panels?: ObservedArray<EngagementPanelSectionList>;
  items?: SuperParsedResult<YTNode>;
  entries?: NavigationEndpoint[];
  entries_memo?: Memo;
  continuation_endpoint?: YTNode;
  player_response?: IPlayerResponse;
  watch_next_response?: INextResponse;
}

export interface IPlaybackTracking {
  videostats_watchtime_url: string;
  videostats_playback_url: string;
}
export interface IPlayabilityStatus {
  status: string;
  error_screen: YTNode | null;
  audio_only_playablility: AudioOnlyPlayability | null;
  embeddable: boolean;
  reason: string;
}

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[];
  adaptive_formats: Format[];
  dash_manifest_url: string | null;
  hls_manifest_url: string | null;
}

export type IPlayerResponse = Pick<IParsedResponse, 'captions' | 'cards' | 'endscreen' | 'microformat' | 'annotations' | 'playability_status' | 'streaming_data' | 'player_config' | 'playback_tracking' | 'storyboards' | 'video_details'>;
export type INextResponse = Pick<IParsedResponse, 'contents' | 'contents_memo' | 'current_video_endpoint' | 'on_response_received_endpoints' | 'on_response_received_endpoints_memo' | 'player_overlays' | 'engagement_panels'>
export type IBrowseResponse = Pick<IParsedResponse, 'background' | 'continuation_contents' | 'continuation_contents_memo' | 'on_response_received_actions' | 'on_response_received_actions_memo' | 'on_response_received_endpoints' | 'on_response_received_endpoints_memo' | 'contents' | 'contents_memo' | 'header' | 'header_memo' | 'metadata' | 'microformat' | 'alerts' | 'sidebar' | 'sidebar_memo'>
export type ISearchResponse = Pick<IParsedResponse, 'header' | 'header_memo' | 'contents' | 'contents_memo' | 'on_response_received_commands' | 'continuation_contents' | 'continuation_contents_memo' | 'refinements' | 'estimated_results'>;
export type IResolveURLResponse = Pick<IParsedResponse, 'endpoint'>;
export type IGetTranscriptResponse = Pick<IParsedResponse, 'actions' | 'actions_memo'>
export type IGetNotificationsMenuResponse = Pick<IParsedResponse, 'actions' | 'actions_memo'>
export type IUpdatedMetadataResponse = Pick<IParsedResponse, 'actions' | 'actions_memo' | 'continuation'>
export type IGuideResponse = Pick<IParsedResponse, 'items' | 'items_memo'>

LuanRT/YouTube.js/blob/main/src/parser/types/RawResponse.ts:

export type RawNode = Record<string, any>;
export type RawData = RawNode | RawNode[];

export type CpnSource = 'CPN_SOURCE_TYPE_CLIENT' | 'CPN_SOURCE_TYPE_WATCH_SERVER';

export interface IServiceTrackingParams {
  service: string;
  params?: {
    key: string;
    value: string;
  }[];
}

export interface IResponseContext {
  serviceTrackingParams: IServiceTrackingParams[];
  maxAgeSeconds: number;
}

export interface IRawPlayerConfig {
  audioConfig: {
    loudnessDb?: number;
    perceptualLoudnessDb?: number;
    enablePerFormatLoudness: boolean;
  };
  streamSelectionConfig: {
    maxBitrate: string;
  };
  mediaCommonConfig: {
    dynamicReadaheadConfig: {
      maxReadAheadMediaTimeMs: number;
      minReadAheadMediaTimeMs: number;
      readAheadGrowthRateMs: number;
    };
  };
}


export interface IRawResponse {
  responseContext?: IResponseContext;
  background?: RawNode;
  contents?: RawData;
  onResponseReceivedActions?: RawNode[];
  onResponseReceivedEndpoints?: RawNode[];
  onResponseReceivedCommands?: RawNode[];
  continuationContents?: RawNode;
  actions?: RawNode[];
  liveChatItemContextMenuSupportedRenderers?: RawNode;
  header?: RawNode;
  sidebar?: RawNode;
  continuation?: RawNode;
  metadata?: RawNode;
  microformat?: RawNode;
  overlay?: RawNode;
  alerts?: RawNode[];
  refinements?: string[];
  estimatedResults?: string;
  playerOverlays?: RawNode;
  playbackTracking?: {
    videostatsWatchtimeUrl: {
      baseUrl: string;
    };
    videostatsPlaybackUrl: {
      baseUrl: string;
    };
  };
  playabilityStatus?: {
    status: string;
    reason?: string;
    errorScreen?: RawNode;
    audioOnlyPlayability?: RawNode;
    playableInEmbed?: boolean;
  };
  streamingData?: {
    expiresInSeconds: string;
    formats: RawNode[];
    adaptiveFormats: RawNode[];
    dashManifestUrl?: string;
    hlsManifestUrl?: string;
  };
  playerConfig?: IRawPlayerConfig;
  playerResponse?: IRawResponse;
  watchNextResponse?: IRawResponse;
  currentVideoEndpoint?: RawNode;
  unseenCount?: number;
  playlistId?: string;
  endpoint?: RawNode;
  captions?: RawNode;
  videoDetails?: RawNode;
  annotations?: RawNode[];
  storyboards?: RawNode;
  endscreen?: RawNode;
  cards?: RawNode;
  cpnInfo?: {
    cpn: string;
    cpnSource: CpnSource;
  },
  items?: RawNode[];
  frameworkUpdates?: any;
  engagementPanels?: RawNode[];
  entries?: RawNode[];
  [key: string]: any;
}

LuanRT/YouTube.js/blob/main/src/parser/types/index.ts:

export * from './RawResponse.js';
export * from './ParsedResponse.js';

LuanRT/YouTube.js/blob/main/src/parser/youtube/AccountInfo.ts:

import { Parser } from '../index.js';
import { InnertubeError } from '../../utils/Utils.js';
import AccountSectionList from '../classes/AccountSectionList.js';

import type { ApiResponse } from '../../core/index.js';
import type { IParsedResponse } from '../types/index.js';
import type AccountItemSection from '../classes/AccountItemSection.js';
import type AccountChannel from '../classes/AccountChannel.js';

export default class AccountInfo {
  #page: IParsedResponse;

  contents: AccountItemSection | null;
  footers: AccountChannel | null;

  constructor(response: ApiResponse) {
    this.#page = Parser.parseResponse(response.data);

    if (!this.#page.contents)
      throw new InnertubeError('Page contents not found');

    const account_section_list = this.#page.contents.array().as(AccountSectionList).first();

    if (!account_section_list)
      throw new InnertubeError('Account section list not found');

    this.contents = account_section_list.contents;
    this.footers = account_section_list.footers;
  }

  get page(): IParsedResponse {
    return this.#page;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/youtube/Analytics.ts:

import { Parser } from '../index.js';
import Element from '../classes/Element.js';
import type { ApiResponse } from '../../core/index.js';
import type { IBrowseResponse } from '../types/index.js';

export default class Analytics {
  #page: IBrowseResponse;
  sections;

  constructor(response: ApiResponse) {
    this.#page = Parser.parseResponse<IBrowseResponse>(response.data);
    this.sections = this.#page.contents_memo?.getType(Element).map((el) => el.model).flatMap((el) => !el ? [] : el);
  }

  get page(): IBrowseResponse {
    return this.#page;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/youtube/Channel.ts:

import Feed from '../../core/mixins/Feed.js';
import FilterableFeed from '../../core/mixins/FilterableFeed.js';
import { ChannelError, InnertubeError } from '../../utils/Utils.js';

import TabbedFeed from '../../core/mixins/TabbedFeed.js';
import C4TabbedHeader from '../classes/C4TabbedHeader.js';
import CarouselHeader from '../classes/CarouselHeader.js';
import ChannelAboutFullMetadata from '../classes/ChannelAboutFullMetadata.js';
import AboutChannel from '../classes/AboutChannel.js';
import ChannelMetadata from '../classes/ChannelMetadata.js';
import InteractiveTabbedHeader from '../classes/InteractiveTabbedHeader.js';
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 PageHeader from '../classes/PageHeader.js';
import TwoColumnBrowseResults from '../classes/TwoColumnBrowseResults.js';
import ChipCloudChip from '../classes/ChipCloudChip.js';
import FeedFilterChipBar from '../classes/FeedFilterChipBar.js';
import ChannelSubMenu from '../classes/ChannelSubMenu.js';
import SortFilterSubMenu from '../classes/SortFilterSubMenu.js';
import ContinuationItem from '../classes/ContinuationItem.js';
import NavigationEndpoint from '../classes/NavigationEndpoint.js';

import type { AppendContinuationItemsAction, ReloadContinuationItemsCommand } from '../index.js';
import type { ApiResponse, Actions } from '../../core/index.js';
import type { IBrowseResponse } from '../types/index.js';

export default class Channel extends TabbedFeed<IBrowseResponse> {
  header?: C4TabbedHeader | CarouselHeader | InteractiveTabbedHeader | PageHeader;
  metadata;
  subscribe_button?: SubscribeButton;
  current_tab?: Tab | ExpandableTab;

  constructor(actions: Actions, data: ApiResponse | IBrowseResponse, already_parsed = false) {
    super(actions, data, already_parsed);

    this.header = this.page.header?.item()?.as(C4TabbedHeader, CarouselHeader, InteractiveTabbedHeader, PageHeader);

    const metadata = this.page.metadata?.item().as(ChannelMetadata);
    const microformat = this.page.microformat?.as(MicroformatData);

    if (this.page.alerts) {
      const alert = this.page.alerts.first();
      if (alert?.alert_type === 'ERROR') {
        throw new ChannelError(alert.text.toString());
      }
    }

    if (!metadata && !this.page.contents)
      throw new InnertubeError('Invalid channel', this);

    this.metadata = { ...metadata, ...(microformat || {}) };

    this.subscribe_button = this.page.header_memo?.getType(SubscribeButton).first();

    this.current_tab = this.page.contents?.item().as(TwoColumnBrowseResults).tabs.array().filterType(Tab, ExpandableTab).get({ selected: true });
  }

  /**
   * Applies given filter to the list. Use {@link filters} to get available filters.
   * @param filter - The filter to apply
   */
  async applyFilter(filter: string | ChipCloudChip): Promise<FilteredChannelList> {
    let target_filter: ChipCloudChip | undefined;

    const filter_chipbar = this.memo.getType(FeedFilterChipBar).first();

    if (typeof filter === 'string') {
      target_filter = filter_chipbar?.contents.get({ text: filter });
      if (!target_filter)
        throw new InnertubeError(`Filter ${filter} not found`, { available_filters: this.filters });
    } else if (filter instanceof ChipCloudChip) {
      target_filter = filter;
    }

    if (!target_filter)
      throw new InnertubeError('Invalid filter', filter);

    const page = await target_filter.endpoint?.call<IBrowseResponse>(this.actions, { parse: true });

    if (!page)
      throw new InnertubeError('No page returned', { filter: target_filter });

    return new FilteredChannelList(this.actions, page, true);
  }

  /**
   * Applies given sort filter to the list. Use {@link sort_filters} to get available filters.
   * @param sort - The sort filter to apply
   */
  async applySort(sort: string): Promise<Channel> {
    const sort_filter_sub_menu = this.memo.getType(SortFilterSubMenu).first();

    if (!sort_filter_sub_menu)
      throw new InnertubeError('No sort filter sub menu found');

    const target_sort = sort_filter_sub_menu?.sub_menu_items?.find((item) => item.title === sort);

    if (!target_sort)
      throw new InnertubeError(`Sort filter ${sort} not found`, { available_sort_filters: this.sort_filters });

    if (target_sort.selected)
      return this;

    const page = await target_sort.endpoint?.call<IBrowseResponse>(this.actions, { parse: true });

    return new Channel(this.actions, page, true);
  }

  /**
   * Applies given content type filter to the list. Use {@link content_type_filters} to get available filters.
   * @param content_type_filter - The content type filter to apply
   */
  async applyContentTypeFilter(content_type_filter: string): Promise<Channel> {
    const sub_menu = this.current_tab?.content?.as(SectionList).sub_menu?.as(ChannelSubMenu);

    if (!sub_menu)
      throw new InnertubeError('Sub menu not found');

    const item = sub_menu.content_type_sub_menu_items.find((item) => item.title === content_type_filter);

    if (!item)
      throw new InnertubeError(`Sub menu item ${content_type_filter} not found`, { available_filters: this.content_type_filters });

    if (item.selected)
      return this;

    const page = await item.endpoint?.call<IBrowseResponse>(this.actions, { parse: true });

    return new Channel(this.actions, page, true);
  }

  get filters(): string[] {
    return this.memo.getType(FeedFilterChipBar)?.[0]?.contents.filterType(ChipCloudChip).map((chip) => chip.text) || [];
  }

  get sort_filters(): string[] {
    const sort_filter_sub_menu = this.memo.getType(SortFilterSubMenu).first();
    return sort_filter_sub_menu?.sub_menu_items?.map((item) => item.title) || [];
  }

  get content_type_filters(): string[] {
    const sub_menu = this.current_tab?.content?.as(SectionList).sub_menu?.as(ChannelSubMenu);
    return sub_menu?.content_type_sub_menu_items.map((item) => item.title) || [];
  }

  async getHome(): Promise<Channel> {
    const tab = await this.getTabByURL('featured');
    return new Channel(this.actions, tab.page, true);
  }

  async getVideos(): Promise<Channel> {
    const tab = await this.getTabByURL('videos');
    return new Channel(this.actions, tab.page, true);
  }

  async getShorts(): Promise<Channel> {
    const tab = await this.getTabByURL('shorts');
    return new Channel(this.actions, tab.page, true);
  }

  async getLiveStreams(): Promise<Channel> {
    const tab = await this.getTabByURL('streams');
    return new Channel(this.actions, tab.page, true);
  }

  async getReleases(): Promise<Channel> {
    const tab = await this.getTabByURL('releases');
    return new Channel(this.actions, tab.page, true);
  }

  async getPodcasts(): Promise<Channel> {
    const tab = await this.getTabByURL('podcasts');
    return new Channel(this.actions, tab.page, true);
  }

  async getPlaylists(): Promise<Channel> {
    const tab = await this.getTabByURL('playlists');
    return new Channel(this.actions, tab.page, true);
  }

  async getCommunity(): Promise<Channel> {
    const tab = await this.getTabByURL('community');
    return new Channel(this.actions, tab.page, true);
  }

  /**
   * Retrieves the about page.
   * Note that this does not return a new {@link Channel} object.
   */
  async getAbout(): Promise<ChannelAboutFullMetadata | AboutChannel> {
    if (this.hasTabWithURL('about')) {
      const tab = await this.getTabByURL('about');
      return tab.memo.getType(ChannelAboutFullMetadata)[0];
    }

    const tagline = this.header?.is(C4TabbedHeader) && this.header.tagline;

    if (tagline || this.header?.is(PageHeader) && this.header.content?.description) {
      if (tagline && tagline.more_endpoint instanceof NavigationEndpoint) {
        const response = await tagline.more_endpoint.call(this.actions);

        const tab = new TabbedFeed<IBrowseResponse>(this.actions, response, false);
        return tab.memo.getType(ChannelAboutFullMetadata)[0];
      }

      const endpoint = this.page.header_memo?.getType(ContinuationItem)[0]?.endpoint;

      if (!endpoint) {
        throw new InnertubeError('Failed to extract continuation to get channel about');
      }

      const response = await endpoint.call<IBrowseResponse>(this.actions, { parse: true });

      if (!response.on_response_received_endpoints_memo) {
        throw new InnertubeError('Unexpected response while fetching channel about', { response });
      }

      return response.on_response_received_endpoints_memo.getType(AboutChannel)[0];
    }

    throw new InnertubeError('About not found');
  }

  /**
   * Searches within the channel.
   */
  async search(query: string): Promise<Channel> {
    const tab = this.memo.getType(ExpandableTab)?.[0];

    if (!tab)
      throw new InnertubeError('Search tab not found', this);

    const page = await tab.endpoint?.call<IBrowseResponse>(this.actions, { query, parse: true });

    return new Channel(this.actions, page, true);
  }

  get has_home(): boolean {
    return this.hasTabWithURL('featured');
  }

  get has_videos(): boolean {
    return this.hasTabWithURL('videos');
  }

  get has_shorts(): boolean {
    return this.hasTabWithURL('shorts');
  }

  get has_live_streams(): boolean {
    return this.hasTabWithURL('streams');
  }

  get has_releases(): boolean {
    return this.hasTabWithURL('releases');
  }

  get has_podcasts(): boolean {
    return this.hasTabWithURL('podcasts');
  }

  get has_playlists(): boolean {
    return this.hasTabWithURL('playlists');
  }

  get has_community(): boolean {
    return this.hasTabWithURL('community');
  }

  get has_about(): boolean {
    // Game topic channels still have an about tab, user channels have switched to the popup
    return this.hasTabWithURL('about') ||
      !!(this.header?.is(C4TabbedHeader) && this.header.tagline?.more_endpoint) ||
      !!(this.header?.is(PageHeader) && this.header.content?.description?.more_endpoint);
  }

  get has_search(): boolean {
    return this.memo.getType(ExpandableTab)?.length > 0;
  }

  /**
   * Retrives list continuation.
   */
  async getContinuation(): Promise<ChannelListContinuation> {
    const page = await super.getContinuationData();
    if (!page)
      throw new InnertubeError('Could not get continuation data');
    return new ChannelListContinuation(this.actions, page, true);
  }
}

export class ChannelListContinuation extends Feed<IBrowseResponse> {
  contents?: ReloadContinuationItemsCommand | AppendContinuationItemsAction;

  constructor(actions: Actions, data: ApiResponse | IBrowseResponse, already_parsed = false) {
    super(actions, data, already_parsed);
    this.contents =
      this.page.on_response_received_actions?.first() ||
      this.page.on_response_received_endpoints?.first();
  }

  /**
   * Retrieves list continuation.
   */
  async getContinuation(): Promise<ChannelListContinuation> {
    const page = await super.getContinuationData();
    if (!page)
      throw new InnertubeError('Could not get continuation data');
    return new ChannelListContinuation(this.actions, page, true);
  }
}

export class FilteredChannelList extends FilterableFeed<IBrowseResponse> {
  applied_filter?: ChipCloudChip;
  contents?: ReloadContinuationItemsCommand | AppendContinuationItemsAction;

  constructor(actions: Actions, data: ApiResponse | IBrowseResponse, already_parsed = false) {
    super(actions, data, already_parsed);

    this.applied_filter = this.memo.getType(ChipCloudChip).get({ is_selected: true });

    // Removes the filter chipbar from the actions list
    if (
      this.page.on_response_received_actions &&
      this.page.on_response_received_actions.length > 1
    ) {
      this.page.on_response_received_actions.shift();
    }

    this.contents = this.page.on_response_received_actions?.first();
  }

  /**
   * Applies given filter to the list.
   * @param filter - The filter to apply
   */
  async applyFilter(filter: string | ChipCloudChip): Promise<FilteredChannelList> {
    const feed = await super.getFilteredFeed(filter);
    return new FilteredChannelList(this.actions, feed.page, true);
  }

  /**
   * Retrieves list continuation.
   */
  async getContinuation(): Promise<FilteredChannelList> {
    const page = await super.getContinuationData();

    if (!page?.on_response_received_actions_memo)
      throw new InnertubeError('Unexpected continuation data', page);

    // Keep the filters
    page.on_response_received_actions_memo.set('FeedFilterChipBar', this.memo.getType(FeedFilterChipBar));
    page.on_response_received_actions_memo.set('ChipCloudChip', this.memo.getType(ChipCloudChip));

    return new FilteredChannelList(this.actions, page, true);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/youtube/Comments.ts:

import { Parser } from '../index.js';
import { InnertubeError } from '../../utils/Utils.js';
import { observe } from '../helpers.js';

import CommentsHeader from '../classes/comments/CommentsHeader.js';
import CommentSimplebox from '../classes/comments/CommentSimplebox.js';
import CommentThread from '../classes/comments/CommentThread.js';
import ContinuationItem from '../classes/ContinuationItem.js';

import type { Actions, ApiResponse } from '../../core/index.js';
import type { ObservedArray } from '../helpers.js';
import type { INextResponse } from '../types/index.js';

export default class Comments {
  #page: INextResponse;
  #actions: Actions;
  #continuation?: ContinuationItem;

  header?: CommentsHeader;
  contents: ObservedArray<CommentThread>;

  constructor(actions: Actions, data: any, already_parsed = false) {
    this.#page = already_parsed ? data : Parser.parseResponse<INextResponse>(data);
    this.#actions = actions;

    const contents = this.#page.on_response_received_endpoints;

    if (!contents)
      throw new InnertubeError('Comments page did not have any content.');

    const header_node = contents.at(0);
    const body_node = contents.at(1);

    this.header = header_node?.contents?.firstOfType(CommentsHeader);

    const threads = body_node?.contents?.filterType(CommentThread) || [];

    this.contents = observe(threads.map((thread) => {
      thread.comment?.setActions(this.#actions);
      thread.setActions(this.#actions);
      return thread;
    }));

    this.#continuation = body_node?.contents?.firstOfType(ContinuationItem);
  }

  /**
   * Applies given sort option to the comments.
   * @param sort - Sort type.
   */
  async applySort(sort: 'TOP_COMMENTS' | 'NEWEST_FIRST'): Promise<Comments> {
    if (!this.header)
      throw new InnertubeError('Page header is missing. Cannot apply sort option.');

    let button;

    if (sort === 'TOP_COMMENTS') {
      button = this.header.sort_menu?.sub_menu_items?.at(0);
    } else if (sort === 'NEWEST_FIRST') {
      button = this.header.sort_menu?.sub_menu_items?.at(1);
    }

    if (!button)
      throw new InnertubeError('Could not find target button.');

    if (button.selected)
      return this;

    const response = await button.endpoint.call(this.#actions, { parse: true });

    return new Comments(this.#actions, response, true);
  }

  /**
   * Creates a top-level comment.
   * @param text - Comment text.
   */
  async createComment(text: string): Promise<ApiResponse> {
    if (!this.header)
      throw new InnertubeError('Page header is missing. Cannot create comment.');

    const button = this.header.create_renderer?.as(CommentSimplebox).submit_button;

    if (!button)
      throw new InnertubeError('Could not find target button. You are probably not logged in.');

    if (!button.endpoint)
      throw new InnertubeError('Button does not have an endpoint.');

    const response = await button.endpoint.call(this.#actions, { commentText: text });

    return response;
  }

  /**
   * Retrieves next batch of comments.
   */
  async getContinuation(): Promise<Comments> {
    if (!this.#continuation)
      throw new InnertubeError('Continuation not found');

    const data = await this.#continuation.endpoint.call(this.#actions, { parse: true });

    // Copy the previous page so we can keep the header.
    const page = Object.assign({}, this.#page);

    if (!page.on_response_received_endpoints || !data.on_response_received_endpoints)
      throw new InnertubeError('Invalid reponse format, missing on_response_received_endpoints.');

    // Remove previous items and append the continuation.
    page.on_response_received_endpoints.pop();
    page.on_response_received_endpoints.push(data.on_response_received_endpoints[0]);

    return new Comments(this.#actions, page, true);
  }

  get has_continuation(): boolean {
    return !!this.#continuation;
  }

  get page(): INextResponse {
    return this.#page;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/youtube/Guide.ts:

import { Parser } from '../index.js';
import GuideSection from '../classes/GuideSection.js';
import GuideSubscriptionsSection from '../classes/GuideSubscriptionsSection.js';

import type { ObservedArray } from '../helpers.js';
import type { IGuideResponse } from '../types/index.js';
import type { IRawResponse } from '../index.js';

export default class Guide {
  #page: IGuideResponse;
  contents?: ObservedArray<GuideSection | GuideSubscriptionsSection>;

  constructor(data: IRawResponse) {
    this.#page = Parser.parseResponse<IGuideResponse>(data);
    if (this.#page.items)
      this.contents = this.#page.items.array().as(GuideSection, GuideSubscriptionsSection);
  }

  get page(): IGuideResponse {
    return this.#page;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/youtube/HashtagFeed.ts:

import { InnertubeError } from '../../utils/Utils.js';
import FilterableFeed from '../../core/mixins/FilterableFeed.js';
import HashtagHeader from '../classes/HashtagHeader.js';
import RichGrid from '../classes/RichGrid.js';
import PageHeader from '../classes/PageHeader.js';
import Tab from '../classes/Tab.js';

import type { Actions, ApiResponse } from '../../core/index.js';
import type { IBrowseResponse } from '../index.js';
import type ChipCloudChip from '../classes/ChipCloudChip.js';

export default class HashtagFeed extends FilterableFeed<IBrowseResponse> {
  header?: HashtagHeader | PageHeader;
  contents: RichGrid;

  constructor(actions: Actions, response: IBrowseResponse | ApiResponse) {
    super(actions, response);

    if (!this.page.contents_memo)
      throw new InnertubeError('Unexpected response', this.page);

    const tab = this.page.contents_memo.getType(Tab).first();

    if (!tab.content)
      throw new InnertubeError('Content tab has no content', tab);

    if (this.page.header) {
      this.header = this.page.header.item().as(HashtagHeader, PageHeader);
    }

    this.contents = tab.content.as(RichGrid);
  }

  /**
   * Applies given filter and returns a new {@link HashtagFeed} object. Use {@link HashtagFeed.filters} to get available filters.
   * @param filter - Filter to apply.
   */
  async applyFilter(filter: string | ChipCloudChip): Promise<HashtagFeed> {
    const response = await super.getFilteredFeed(filter);
    return new HashtagFeed(this.actions, response.page);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/youtube/History.ts:

import Feed from '../../core/mixins/Feed.js';
import ItemSection from '../classes/ItemSection.js';
import BrowseFeedActions from '../classes/BrowseFeedActions.js';

import type { Actions, ApiResponse } from '../../core/index.js';
import type { IBrowseResponse } from '../types/index.js';

// TODO: make feed actions usable
export default class History extends Feed<IBrowseResponse> {
  sections: ItemSection[];
  feed_actions: BrowseFeedActions;

  constructor(actions: Actions, data: ApiResponse | IBrowseResponse, already_parsed = false) {
    super(actions, data, already_parsed);
    this.sections = this.memo.getType(ItemSection);
    this.feed_actions = this.memo.getType(BrowseFeedActions).first();
  }

  /**
   * Retrieves next batch of contents.
   */
  async getContinuation(): Promise<History> {
    const response = await this.getContinuationData();
    if (!response)
      throw new Error('No continuation data found');
    return new History(this.actions, response, true);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/youtube/HomeFeed.ts:

import FilterableFeed from '../../core/mixins/FilterableFeed.js';
import FeedTabbedHeader from '../classes/FeedTabbedHeader.js';
import RichGrid from '../classes/RichGrid.js';

import type { IBrowseResponse } from '../types/index.js';
import type { AppendContinuationItemsAction, ReloadContinuationItemsCommand } from '../index.js';
import type { ApiResponse, Actions } from '../../core/index.js';
import type ChipCloudChip from '../classes/ChipCloudChip.js';

export default class HomeFeed extends FilterableFeed<IBrowseResponse> {
  contents?: RichGrid | AppendContinuationItemsAction | ReloadContinuationItemsCommand;
  header?: FeedTabbedHeader;

  constructor(actions: Actions, data: ApiResponse | IBrowseResponse, already_parsed = false) {
    super(actions, data, already_parsed);
    this.header = this.memo.getType(FeedTabbedHeader).first();
    this.contents = this.memo.getType(RichGrid).first() || this.page.on_response_received_actions?.first();
  }

  /**
   * Applies given filter to the feed. Use {@link filters} to get available filters.
   * @param filter - Filter to apply.
   */
  async applyFilter(filter: string | ChipCloudChip): Promise<HomeFeed> {
    const feed = await super.getFilteredFeed(filter);
    return new HomeFeed(this.actions, feed.page, true);
  }

  /**
   * Retrieves next batch of contents.
   */
  async getContinuation(): Promise<HomeFeed> {
    const feed = await super.getContinuation();

    // Keep the page header
    feed.page.header = this.page.header;

    if (this.header)
      feed.page.header_memo?.set(this.header.type, [ this.header ]);

    return new HomeFeed(this.actions, feed.page, true);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/youtube/ItemMenu.ts:

import Menu from '../classes/menus/Menu.js';
import Button from '../classes/Button.js';
import MenuServiceItem from '../classes/menus/MenuServiceItem.js';

import type { Actions } from '../../core/index.js';
import { InnertubeError } from '../../utils/Utils.js';
import type { ObservedArray, YTNode } from '../helpers.js';
import type { IParsedResponse } from '../types/index.js';
import type NavigationEndpoint from '../classes/NavigationEndpoint.js';

export default class ItemMenu {
  #page: IParsedResponse;
  #actions: Actions;
  #items: ObservedArray<YTNode>;

  constructor(data: IParsedResponse, actions: Actions) {
    this.#page = data;
    this.#actions = actions;

    const menu = data?.live_chat_item_context_menu_supported_renderers;

    if (!menu || !menu.is(Menu))
      throw new InnertubeError('Response did not have a "live_chat_item_context_menu_supported_renderers" property. The call may have failed.');

    this.#items = menu.as(Menu).items;
  }

  async selectItem(icon_type: string): Promise<IParsedResponse>
  async selectItem(button: Button): Promise<IParsedResponse>
  async selectItem(item: string | Button): Promise<IParsedResponse> {
    let endpoint: NavigationEndpoint | undefined;

    if (item instanceof Button) {
      if (!item.endpoint)
        throw new InnertubeError('Item does not have an endpoint.');

      endpoint = item.endpoint;
    } else {
      const button = this.#items.find((button) => {
        if (!button.is(MenuServiceItem)) {
          return false;
        }
        const menuServiceItem = button.as(MenuServiceItem);
        return menuServiceItem.icon_type === item;
      });

      if (!button || !button.is(MenuServiceItem))
        throw new InnertubeError(`Button "${item}" not found.`);

      endpoint = button.endpoint;
    }

    if (!endpoint)
      throw new InnertubeError('Target button does not have an endpoint.');

    const response = await endpoint.call(this.#actions, { parse: true });

    return response;
  }

  items(): ObservedArray<YTNode> {
    return this.#items;
  }

  page(): IParsedResponse {
    return this.#page;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/youtube/Library.ts:

import { InnertubeError } from '../../utils/Utils.js';
import Feed from '../../core/mixins/Feed.js';
import History from './History.js';
import Playlist from './Playlist.js';
import Menu from '../classes/menus/Menu.js';
import Shelf from '../classes/Shelf.js';
import Button from '../classes/Button.js';
import PageHeader from '../classes/PageHeader.js';

import type { Actions, ApiResponse } from '../../core/index.js';
import type { IBrowseResponse } from '../types/index.js';

export default class Library extends Feed<IBrowseResponse> {
  header: PageHeader | null;
  sections;

  constructor(actions: Actions, data: ApiResponse | IBrowseResponse) {
    super(actions, data);

    if (!this.page.contents_memo)
      throw new InnertubeError('Page contents not found');

    this.header = this.memo.getType(PageHeader).first();

    const shelves = this.page.contents_memo.getType(Shelf);

    this.sections = shelves.map((shelf: Shelf) => ({
      type: shelf.icon_type,
      title: shelf.title,
      contents: shelf.content?.key('items').array() || [],
      getAll: () => this.#getAll(shelf)
    }));
  }

  async #getAll(shelf: Shelf): Promise<Playlist | History | Feed<IBrowseResponse>> {
    if (!shelf.menu?.as(Menu).hasKey('top_level_buttons'))
      throw new InnertubeError(`The ${shelf.title.text} shelf doesn't have more items`);

    const button = shelf.menu.as(Menu).top_level_buttons.firstOfType(Button);

    if (!button)
      throw new InnertubeError('Did not find target button.');

    const page = await button.as(Button).endpoint.call<IBrowseResponse>(this.actions, { parse: true });

    switch (shelf.icon_type) {
      case 'LIKE':
      case 'WATCH_LATER':
        return new Playlist(this.actions, page, true);
      case 'WATCH_HISTORY':
        return new History(this.actions, page, true);
      case 'CONTENT_CUT':
        return new Feed(this.actions, page, true);
      default:
        throw new InnertubeError('Target shelf not implemented.');
    }
  }

  get history() {
    return this.sections.find((section) => section.type === 'WATCH_HISTORY');
  }

  get watch_later() {
    return this.sections.find((section) => section.type === 'WATCH_LATER');
  }

  get liked_videos() {
    return this.sections.find((section) => section.type === 'LIKE');
  }

  get playlists_section() {
    return this.sections.find((section) => section.type === 'PLAYLISTS');
  }

  get clips() {
    return this.sections.find((section) => section.type === 'CONTENT_CUT');
  }
}

LuanRT/YouTube.js/blob/main/src/parser/youtube/LiveChat.ts:

import * as Proto from '../../proto/index.js';
import { EventEmitter } from '../../utils/index.js';
import { InnertubeError, Platform } from '../../utils/Utils.js';
import { Parser, LiveChatContinuation } from '../index.js';
import SmoothedQueue from './SmoothedQueue.js';

import AddChatItemAction from '../classes/livechat/AddChatItemAction.js';
import UpdateDateTextAction from '../classes/livechat/UpdateDateTextAction.js';
import UpdateDescriptionAction from '../classes/livechat/UpdateDescriptionAction.js';
import UpdateTitleAction from '../classes/livechat/UpdateTitleAction.js';
import UpdateToggleButtonTextAction from '../classes/livechat/UpdateToggleButtonTextAction.js';
import UpdateViewershipAction from '../classes/livechat/UpdateViewershipAction.js';
import NavigationEndpoint from '../classes/NavigationEndpoint.js';
import ItemMenu from './ItemMenu.js';

import type { ObservedArray, YTNode } from '../helpers.js';

import type VideoInfo from './VideoInfo.js';
import type AddBannerToLiveChatCommand from '../classes/livechat/AddBannerToLiveChatCommand.js';
import type RemoveBannerForLiveChatCommand from '../classes/livechat/RemoveBannerForLiveChatCommand.js';
import type ShowLiveChatTooltipCommand from '../classes/livechat/ShowLiveChatTooltipCommand.js';
import type LiveChatAutoModMessage from '../classes/livechat/items/LiveChatAutoModMessage.js';
import type LiveChatMembershipItem from '../classes/livechat/items/LiveChatMembershipItem.js';
import type LiveChatPaidMessage from '../classes/livechat/items/LiveChatPaidMessage.js';
import type LiveChatPaidSticker from '../classes/livechat/items/LiveChatPaidSticker.js';
import type LiveChatTextMessage from '../classes/livechat/items/LiveChatTextMessage.js';
import type LiveChatViewerEngagementMessage from '../classes/livechat/items/LiveChatViewerEngagementMessage.js';
import type AddLiveChatTickerItemAction from '../classes/livechat/AddLiveChatTickerItemAction.js';
import type MarkChatItemAsDeletedAction from '../classes/livechat/MarkChatItemAsDeletedAction.js';
import type MarkChatItemsByAuthorAsDeletedAction from '../classes/livechat/MarkChatItemsByAuthorAsDeletedAction.js';
import type ReplaceChatItemAction from '../classes/livechat/ReplaceChatItemAction.js';
import type ReplayChatItemAction from '../classes/livechat/ReplayChatItemAction.js';
import type ShowLiveChatActionPanelAction from '../classes/livechat/ShowLiveChatActionPanelAction.js';
import type Button from '../classes/Button.js';

import type { Actions } from '../../core/index.js';
import type { IParsedResponse, IUpdatedMetadataResponse } from '../types/index.js';

export type ChatAction =
  AddChatItemAction | AddBannerToLiveChatCommand | AddLiveChatTickerItemAction |
  MarkChatItemAsDeletedAction | MarkChatItemsByAuthorAsDeletedAction | RemoveBannerForLiveChatCommand |
  ReplaceChatItemAction | ReplayChatItemAction | ShowLiveChatActionPanelAction | ShowLiveChatTooltipCommand;

export type ChatItemWithMenu = LiveChatAutoModMessage | LiveChatMembershipItem | LiveChatPaidMessage | LiveChatPaidSticker | LiveChatTextMessage | LiveChatViewerEngagementMessage;

export interface LiveMetadata {
  title?: UpdateTitleAction;
  description?: UpdateDescriptionAction;
  views?: UpdateViewershipAction;
  likes?: UpdateToggleButtonTextAction;
  date?: UpdateDateTextAction;
}

export default class LiveChat extends EventEmitter {
  smoothed_queue: SmoothedQueue;

  #actions: Actions;
  #video_id: string;
  #channel_id: string;
  #continuation?: string;
  #mcontinuation?: string;
  #retry_count = 0;

  initial_info?: LiveChatContinuation;
  metadata?: LiveMetadata;

  running = false;
  is_replay = false;

  constructor(video_info: VideoInfo) {
    super();

    this.#video_id = video_info.basic_info.id as string;
    this.#channel_id = video_info.basic_info.channel_id as string;
    this.#actions = video_info.actions;
    this.#continuation = video_info.livechat?.continuation;
    this.is_replay = video_info.livechat?.is_replay || false;
    this.smoothed_queue = new SmoothedQueue();

    this.smoothed_queue.callback = async (actions: YTNode[]) => {
      if (!actions.length) {
        // Wait 2 seconds before requesting an incremental continuation if the action group is empty.
        await this.#wait(2000);
      } else if (actions.length < 10) {
        // If there are less than 10 actions, wait until all of them are emitted.
        await this.#emitSmoothedActions(actions);
      } else if (this.is_replay) {
        /**
         * NOTE: Live chat replays require data from the video player for actions to be emitted timely
         * and as we don't have that, this ends up being quite innacurate.
         */
        this.#emitSmoothedActions(actions);
        await this.#wait(2000);
      } else {
        // There are more than 10 actions, emit them asynchonously so we can request the next incremental continuation.
        this.#emitSmoothedActions(actions);
      }

      if (this.running) {
        this.#pollLivechat();
      }
    };
  }

  on(type: 'start', listener: (initial_data: LiveChatContinuation) => void): void;
  on(type: 'chat-update', listener: (action: ChatAction) => void): void;
  on(type: 'metadata-update', listener: (metadata: LiveMetadata) => void): void;
  on(type: 'error', listener: (err: Error) => void): void;
  on(type: 'end', listener: () => void): void;
  on(type: string, listener: (...args: any[]) => void): void {
    super.on(type, listener);
  }

  once(type: 'start', listener: (initial_data: LiveChatContinuation) => void): void;
  once(type: 'chat-update', listener: (action: ChatAction) => void): void;
  once(type: 'metadata-update', listener: (metadata: LiveMetadata) => void): void;
  once(type: 'error', listener: (err: Error) => void): void;
  once(type: 'end', listener: () => void): void;
  once(type: string, listener: (...args: any[]) => void): void {
    super.once(type, listener);
  }

  start() {
    if (!this.running) {
      this.running = true;
      this.#pollLivechat();
      this.#pollMetadata();
    }
  }

  stop() {
    this.smoothed_queue.clear();
    this.running = false;
  }

  #pollLivechat() {
    (async () => {
      try {
        const response = await this.#actions.execute(
          this.is_replay ? 'live_chat/get_live_chat_replay' : 'live_chat/get_live_chat',
          { continuation: this.#continuation, parse: true }
        );

        const contents = response.continuation_contents;

        if (!contents) {
          this.emit('error', new InnertubeError('Unexpected live chat incremental continuation response', response));
          this.emit('end');
          this.stop();
        }

        if (!(contents instanceof LiveChatContinuation)) {
          this.stop();
          this.emit('end');
          return;
        }

        this.#continuation = contents.continuation.token;

        // Header only exists in the first request
        if (contents.header) {
          this.initial_info = contents;
          this.emit('start', contents);
          if (this.running)
            this.#pollLivechat();
        } else {
          this.smoothed_queue.enqueueActionGroup(contents.actions);
        }

        this.#retry_count = 0;
      } catch (err) {
        this.emit('error', err);

        if (this.#retry_count++ < 10) {
          await this.#wait(2000);
          this.#pollLivechat();
        } else {
          this.emit('error', new InnertubeError('Reached retry limit for incremental continuation requests', err));
          this.emit('end');
          this.stop();
        }
      }
    })();
  }

  /**
   * Ensures actions are emitted at the right speed.
   * This and {@link SmoothedQueue} were based off of YouTube's own implementation.
   */
  async #emitSmoothedActions(action_queue: YTNode[]) {
    const base = 1E4;

    let delay = action_queue.length < base / 80 ? 1 : Math.ceil(action_queue.length / (base / 80));

    const emit_delay_ms =
      delay == 1 ? (
        delay = base / action_queue.length,
        delay *= Math.random() + 0.5,
        delay = Math.min(1E3, delay),
        delay = Math.max(80, delay)
      ) : delay = 80;

    for (const action of action_queue) {
      await this.#wait(emit_delay_ms);
      this.emit('chat-update', action);
    }
  }

  #pollMetadata() {
    (async () => {
      try {
        const payload: {
          videoId?: string;
          continuation?: string;
        } = { videoId: this.#video_id };

        if (this.#mcontinuation) {
          payload.continuation = this.#mcontinuation;
        }

        const response = await this.#actions.execute('/updated_metadata', payload);
        const data = Parser.parseResponse<IUpdatedMetadataResponse>(response.data);

        this.#mcontinuation = data.continuation?.token;

        this.metadata = {
          title: data.actions?.array().firstOfType(UpdateTitleAction) || this.metadata?.title,
          description: data.actions?.array().firstOfType(UpdateDescriptionAction) || this.metadata?.description,
          views: data.actions?.array().firstOfType(UpdateViewershipAction) || this.metadata?.views,
          likes: data.actions?.array().firstOfType(UpdateToggleButtonTextAction) || this.metadata?.likes,
          date: data.actions?.array().firstOfType(UpdateDateTextAction) || this.metadata?.date
        };

        this.emit('metadata-update', this.metadata);

        await this.#wait(5000);

        if (this.running)
          this.#pollMetadata();
      } catch (err) {
        await this.#wait(2000);
        if (this.running)
          this.#pollMetadata();
      }
    })();
  }

  /**
   * Sends a message.
   * @param text - Text to send.
   */
  async sendMessage(text: string): Promise<ObservedArray<AddChatItemAction>> {
    const response = await this.#actions.execute('/live_chat/send_message', {
      params: Proto.encodeMessageParams(this.#channel_id, this.#video_id),
      richMessage: { textSegments: [ { text } ] },
      clientMessageId: Platform.shim.uuidv4(),
      client: 'ANDROID',
      parse: true
    });

    if (!response.actions)
      throw new InnertubeError('Unexpected response from send_message', response);

    return response.actions.array().as(AddChatItemAction);
  }

  /**
   * Applies given filter to the live chat.
   * @param filter - Filter to apply.
   */
  applyFilter(filter: 'TOP_CHAT' | 'LIVE_CHAT'): void {
    if (!this.initial_info)
      throw new InnertubeError('Cannot apply filter before initial info is retrieved.');

    const menu_items = this.initial_info?.header?.view_selector?.sub_menu_items;

    if (filter === 'TOP_CHAT') {
      if (menu_items?.at(0)?.selected) return;
      this.#continuation = menu_items?.at(0)?.continuation;
    } else {
      if (menu_items?.at(1)?.selected) return;
      this.#continuation = menu_items?.at(1)?.continuation;
    }
  }

  /**
   * Retrieves given chat item's menu.
   */
  async getItemMenu(item: ChatItemWithMenu): Promise<ItemMenu> {
    if (!item.hasKey('menu_endpoint') || !item.key('menu_endpoint').isInstanceof(NavigationEndpoint))
      throw new InnertubeError('This item does not have a menu.', item);

    const response = await item.key('menu_endpoint').instanceof(NavigationEndpoint).call(this.#actions, { parse: true });

    if (!response)
      throw new InnertubeError('Could not retrieve item menu.', item);

    return new ItemMenu(response, this.#actions);
  }

  /**
   * Equivalent to "clicking" a button.
   */
  async selectButton(button: Button): Promise<IParsedResponse> {
    const response = await button.endpoint.call(this.#actions, { parse: true });
    return response;
  }

  async #wait(ms: number) {
    return new Promise<void>((resolve) => setTimeout(() => resolve(), ms));
  }
}

LuanRT/YouTube.js/blob/main/src/parser/youtube/NotificationsMenu.ts:

import { Parser } from '../index.js';
import { InnertubeError } from '../../utils/Utils.js';

import ContinuationItem from '../classes/ContinuationItem.js';
import SimpleMenuHeader from '../classes/menus/SimpleMenuHeader.js';
import Notification from '../classes/Notification.js';

import type { ApiResponse, Actions } from '../../core/index.js';
import type { IGetNotificationsMenuResponse } from '../types/index.js';

export default class NotificationsMenu {
  #page: IGetNotificationsMenuResponse;
  #actions: Actions;

  header: SimpleMenuHeader;
  contents: Notification[];

  constructor(actions: Actions, response: ApiResponse) {
    this.#actions = actions;
    this.#page = Parser.parseResponse<IGetNotificationsMenuResponse>(response.data);

    if (!this.#page.actions_memo)
      throw new InnertubeError('Page actions not found');

    this.header = this.#page.actions_memo.getType(SimpleMenuHeader).first();
    this.contents = this.#page.actions_memo.getType(Notification);
  }

  async getContinuation(): Promise<NotificationsMenu> {
    const continuation = this.#page.actions_memo?.getType(ContinuationItem).first();

    if (!continuation)
      throw new InnertubeError('Continuation not found');

    const response = await continuation.endpoint.call(this.#actions, { parse: false });

    return new NotificationsMenu(this.#actions, response);
  }

  get page(): IGetNotificationsMenuResponse {
    return this.#page;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/youtube/Playlist.ts:

import { InnertubeError } from '../../utils/Utils.js';

import Feed from '../../core/mixins/Feed.js';
import Message from '../classes/Message.js';
import PlaylistCustomThumbnail from '../classes/PlaylistCustomThumbnail.js';
import PlaylistHeader from '../classes/PlaylistHeader.js';
import PlaylistMetadata from '../classes/PlaylistMetadata.js';
import PlaylistSidebarPrimaryInfo from '../classes/PlaylistSidebarPrimaryInfo.js';
import PlaylistSidebarSecondaryInfo from '../classes/PlaylistSidebarSecondaryInfo.js';
import PlaylistVideoThumbnail from '../classes/PlaylistVideoThumbnail.js';
import ReelItem from '../classes/ReelItem.js';
import VideoOwner from '../classes/VideoOwner.js';
import Alert from '../classes/Alert.js';
import ContinuationItem from '../classes/ContinuationItem.js';
import PlaylistVideo from '../classes/PlaylistVideo.js';
import SectionList from '../classes/SectionList.js';
import { observe, type ObservedArray } from '../helpers.js';

import type { ApiResponse, Actions } from '../../core/index.js';
import type { IBrowseResponse } from '../types/ParsedResponse.js';
import type Thumbnail from '../classes/misc/Thumbnail.js';
import type NavigationEndpoint from '../classes/NavigationEndpoint.js';

export default class Playlist extends Feed<IBrowseResponse> {
  info;
  menu;
  endpoint?: NavigationEndpoint;
  messages: ObservedArray<Message>;

  constructor(actions: Actions, data: ApiResponse | IBrowseResponse, already_parsed = false) {
    super(actions, data, already_parsed);

    const header = this.memo.getType(PlaylistHeader).first();
    const primary_info = this.memo.getType(PlaylistSidebarPrimaryInfo).first();
    const secondary_info = this.memo.getType(PlaylistSidebarSecondaryInfo).first();
    const alert = this.page.alerts?.firstOfType(Alert);

    if (alert && alert.alert_type === 'ERROR')
      throw new InnertubeError(alert.text.toString(), alert);

    if (!primary_info && !secondary_info && Object.keys(this.page).length === 0)
      throw new InnertubeError('Got empty continuation response. This is likely the end of the playlist.');

    this.info = {
      ...this.page.metadata?.item().as(PlaylistMetadata),
      ...{
        subtitle: header ? header.subtitle : null,
        author: secondary_info?.owner?.as(VideoOwner).author ?? header?.author,
        thumbnails: primary_info?.thumbnail_renderer?.as(PlaylistVideoThumbnail, PlaylistCustomThumbnail).thumbnail as Thumbnail[],
        total_items: this.#getStat(0, primary_info),
        views: this.#getStat(1, primary_info),
        last_updated: this.#getStat(2, primary_info),
        can_share: header?.can_share,
        can_delete: header?.can_delete,
        is_editable: header?.is_editable,
        privacy: header?.privacy
      }
    };

    this.menu = primary_info?.menu;
    this.endpoint = primary_info?.endpoint;
    this.messages = this.memo.getType(Message);
  }

  #getStat(index: number, primary_info?: PlaylistSidebarPrimaryInfo): string {
    if (!primary_info || !primary_info.stats) return 'N/A';
    return primary_info.stats[index]?.toString() || 'N/A';
  }

  get items(): ObservedArray<PlaylistVideo | ReelItem> {
    return observe(this.videos.as(PlaylistVideo, ReelItem).filter((video) => (video as PlaylistVideo).style !== 'PLAYLIST_VIDEO_RENDERER_STYLE_RECOMMENDED_VIDEO'));
  }

  get has_continuation() {
    const section_list = this.memo.getType(SectionList).first();

    if (!section_list)
      return super.has_continuation;

    return !!this.memo.getType(ContinuationItem).find((node) => !section_list.contents.includes(node));
  }

  async getContinuationData(): Promise<IBrowseResponse | undefined> {
    const section_list = this.memo.getType(SectionList).first();

    /**
     * No section list means there can't be additional continuation nodes here,
     * so no need to check.
     */
    if (!section_list)
      return await super.getContinuationData();

    const playlist_contents_continuation = this.memo.getType(ContinuationItem)
      .find((node) => !section_list.contents.includes(node));

    if (!playlist_contents_continuation)
      throw new InnertubeError('There are no continuations.');

    const response = await playlist_contents_continuation.endpoint.call<IBrowseResponse>(this.actions, { parse: true });

    return response;
  }

  async getContinuation(): Promise<Playlist> {
    const page = await this.getContinuationData();
    if (!page)
      throw new InnertubeError('Could not get continuation data');
    return new Playlist(this.actions, page, true);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/youtube/Search.ts:

import Feed from '../../core/mixins/Feed.js';
import { InnertubeError } from '../../utils/Utils.js';
import HorizontalCardList from '../classes/HorizontalCardList.js';
import ItemSection from '../classes/ItemSection.js';
import SearchHeader from '../classes/SearchHeader.js';
import SearchRefinementCard from '../classes/SearchRefinementCard.js';
import SearchSubMenu from '../classes/SearchSubMenu.js';
import SectionList from '../classes/SectionList.js';
import UniversalWatchCard from '../classes/UniversalWatchCard.js';

import { observe } from '../helpers.js';

import type { ApiResponse, Actions } from '../../core/index.js';
import type { ObservedArray, YTNode } from '../helpers.js';
import type { ISearchResponse } from '../types/index.js';

export default class Search extends Feed<ISearchResponse> {
  header?: SearchHeader;
  results: ObservedArray<YTNode>;
  refinements: string[];
  estimated_results: number;
  sub_menu?: SearchSubMenu;
  watch_card?: UniversalWatchCard;
  refinement_cards?: HorizontalCardList | null;

  constructor(actions: Actions, data: ApiResponse | ISearchResponse, already_parsed = false) {
    super(actions, data, already_parsed);

    const contents =
      this.page.contents_memo?.getType(SectionList).first().contents ||
      this.page.on_response_received_commands?.first().contents;

    if (!contents)
      throw new InnertubeError('No contents found in search response');

    if (this.page.header)
      this.header = this.page.header.item().as(SearchHeader);

    this.results = observe(contents.filterType(ItemSection).flatMap((section) => section.contents));

    this.refinements = this.page.refinements || [];
    this.estimated_results = this.page.estimated_results || 0;

    if (this.page.contents_memo) {
      this.sub_menu = this.page.contents_memo.getType(SearchSubMenu).first();
      this.watch_card = this.page.contents_memo.getType(UniversalWatchCard).first();
    }

    this.refinement_cards = this.results?.firstOfType(HorizontalCardList);
  }

  /**
   * Applies given refinement card and returns a new {@link Search} object. Use {@link refinement_card_queries} to get a list of available refinement cards.
   */
  async selectRefinementCard(card: SearchRefinementCard | string): Promise<Search> {
    let target_card: SearchRefinementCard | undefined;

    if (typeof card === 'string') {
      if (!this.refinement_cards) throw new InnertubeError('No refinement cards found.');
      target_card = this.refinement_cards?.cards.get({ query: card })?.as(SearchRefinementCard);
      if (!target_card)
        throw new InnertubeError(`Refinement card "${card}" not found`, { available_cards: this.refinement_card_queries });
    } else if (card.type === 'SearchRefinementCard') {
      target_card = card;
    } else {
      throw new InnertubeError('Invalid refinement card!');
    }

    const page = await target_card.endpoint.call<ISearchResponse>(this.actions, { parse: true });

    return new Search(this.actions, page, true);
  }

  /**
   * Returns a list of refinement card queries.
   */
  get refinement_card_queries(): string[] {
    return this.refinement_cards?.cards.as(SearchRefinementCard).map((card) => card.query) || [];
  }

  /**
   * Retrieves next batch of results.
   */
  async getContinuation(): Promise<Search> {
    const response = await this.getContinuationData();
    if (!response)
      throw new InnertubeError('Could not get continuation data');
    return new Search(this.actions, response, true);
  }
}

LuanRT/YouTube.js/blob/main/src/parser/youtube/Settings.ts:

import { Parser } from '../index.js';
import { InnertubeError } from '../../utils/Utils.js';

import CompactLink from '../classes/CompactLink.js';
import ItemSection from '../classes/ItemSection.js';
import PageIntroduction from '../classes/PageIntroduction.js';
import SectionList from '../classes/SectionList.js';
import SettingsOptions from '../classes/SettingsOptions.js';
import SettingsSidebar from '../classes/SettingsSidebar.js';
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';
import type { IBrowseResponse } from '../types/index.js';

export default class Settings {
  #page: IBrowseResponse;
  #actions: Actions;

  sidebar?: SettingsSidebar;
  introduction?: PageIntroduction;
  sections;

  constructor(actions: Actions, response: ApiResponse) {
    this.#actions = actions;
    this.#page = Parser.parseResponse<IBrowseResponse>(response.data);

    this.sidebar = this.#page.sidebar?.as(SettingsSidebar);

    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 });

    if (!tab)
      throw new InnertubeError('Target tab not found');

    const contents = tab.content?.as(SectionList).contents.as(ItemSection);

    this.introduction = contents?.shift()?.contents?.firstOfType(PageIntroduction);

    this.sections = contents?.map((el: ItemSection) => ({
      title: el.header?.is(CommentsHeader, ItemSectionHeader, ItemSectionTabbedHeader) ? el.header.title.toString() : null,
      contents: el.contents
    }));
  }

  /**
   * Selects an item from the sidebar menu. Use {@link sidebar_items} to see available items.
   */
  async selectSidebarItem(target_item: string | CompactLink): Promise<Settings> {
    if (!this.sidebar)
      throw new InnertubeError('Sidebar not available');

    let item: CompactLink | undefined;

    if (typeof target_item === 'string') {
      item = this.sidebar.items.get({ title: target_item });
      if (!item)
        throw new InnertubeError(`Item "${target_item}" not found`, { available_items: this.sidebar_items });
    } else if (target_item?.is(CompactLink)) {
      item = target_item;
    } else {
      throw new InnertubeError('Invalid item', { target_item });
    }

    const response = await item.endpoint.call(this.#actions, { parse: false });

    return new Settings(this.#actions, response);
  }

  /**
   * Finds a setting by name and returns it. Use {@link setting_options} to see available options.
   */
  getSettingOption(name: string): SettingsSwitch {
    if (!this.sections)
      throw new InnertubeError('Sections not available');

    for (const section of this.sections) {
      if (!section.contents) continue;
      for (const el of section.contents) {
        const options = el.as(SettingsOptions).options;
        if (options) {
          for (const option of options) {
            if (
              option.is(SettingsSwitch) &&
              option.title?.toString() === name
            )
              return option;
          }
        }
      }
    }

    throw new InnertubeError(`Option "${name}" not found`, { available_options: this.setting_options });
  }

  /**
   * Returns settings available in the page.
   */
  get setting_options(): string[] {
    if (!this.sections)
      throw new InnertubeError('Sections not available');

    let options: any[] = [];

    for (const section of this.sections) {
      if (!section.contents) continue;
      for (const el of section.contents) {
        if (el.as(SettingsOptions).options)
          options = options.concat(el.as(SettingsOptions).options);
      }
    }

    return options.map((opt) => opt.title?.toString()).filter((el) => el);
  }

  /**
   * Returns options available in the sidebar.
   */
  get sidebar_items(): string[] {
    if (!this.sidebar)
      throw new InnertubeError('Sidebar not available');

    return this.sidebar.items.map((item) => item.title.toString());
  }

  get page(): IBrowseResponse {
    return this.#page;
  }
}

LuanRT/YouTube.js/blob/main/src/parser/youtube/SmoothedQueue.ts:

import type { YTNode } from '../helpers.js';

/**
 * Flattens the given queue.
 * @param queue - The queue to flatten.
 */
function flattenQueue(queue: YTNode[][]) {
  const nodes: YTNode[] = [];

  for (const group of queue) {
    if (Array.isArray(group)) {
      for (const node of group) {
        nodes.push(node);
      }
    } else {
      nodes.push(group);
    }
  }

  return nodes;
}

class DelayQueue {
  front: number[];
  back: number[];

  constructor() {
    this.front = [];
    this.back = [];
  }

  public isEmpty(): boolean {
    return !this.front.length && !this.back.length;
  }

  public clear(): void {
    this.front = [];
    this.back = [];
  }

  public getValues(): number[] {
    return this.front.concat(this.back.reverse());
  }
}

export default class SmoothedQueue {
  #last_update_time: number | null;
  #estimated_update_interval: number | null;
  #callback: Function | null;
  #action_queue: YTNode[][];
  #next_update_id: any;
  #poll_response_delay_queue: DelayQueue;

  constructor() {
    this.#last_update_time = null;
    this.#estimated_update_interval = null;
    this.#callback = null;
    this.#action_queue = [];
    this.#next_update_id = null;
    this.#poll_response_delay_queue = new DelayQueue();
  }

  public enqueueActionGroup(group: YTNode[]): void {
    if (this.#last_update_time !== null) {
      const delay = Date.now() - this.#last_update_time;

      this.#poll_response_delay_queue.back.push(delay);

      if (5 < (this.#poll_response_delay_queue.front.length + this.#poll_response_delay_queue.back.length)) {
        if (!this.#poll_response_delay_queue.front.length) {
          this.#poll_response_delay_queue.front = this.#poll_response_delay_queue.back;
          this.#poll_response_delay_queue.front.reverse();
          this.#poll_response_delay_queue.back = [];
        }

        this.#poll_response_delay_queue.front.pop();
      }

      this.#estimated_update_interval = Math.max(...this.#poll_response_delay_queue.getValues());
    }

    this.#last_update_time = Date.now();

    this.#action_queue.push(group);

    if (this.#next_update_id === null) {
      this.#next_update_id = setTimeout(this.emitSmoothedActions.bind(this));
    }
  }

  public emitSmoothedActions(): void {
    this.#next_update_id = null;

    if (this.#action_queue.length) {
      let delay = 1E4;

      if (this.#estimated_update_interval !== null && this.#last_update_time !== null) {
        delay = this.#estimated_update_interval - Date.now() + this.#last_update_time;
      }

      delay = this.#action_queue.length < delay / 80 ? 1 : Math.ceil(this.#action_queue.length / (delay / 80));

      const actions = flattenQueue(this.#action_queue.splice(0, delay));

      if (this.#callback) {
        this.#callback(actions);
      }

      if (this.#action_queue !== null) {
        delay == 1 ? (
          delay = this.#estimated_update_interval as number / this.#action_queue.length,
          delay *= Math.random() + 0.5,
          delay = Math.min(1E3, delay),
          delay = Math.max(80, delay)
        ) : delay = 80;
View raw

(Sorry about that, but we can’t show files that are this big right now.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment