From e434bb2632fe2b20aab6f1e707a93ca76f9d5c91 Mon Sep 17 00:00:00 2001 From: Emma Date: Wed, 28 Jun 2023 05:54:55 -0400 Subject: [PATCH] fix(VideoInfo.ts): reimplement `get music_tracks` (#409) * fix(VideoInfo.ts): reimplement `get music_tracks` - Add parser classes to parse needed data - Add `CarouselLockup` - Add `EngagementPanelSectionList` - Add `InfoRow` - Add `StructuredDescriptionContent` - Add `VideoDescriptionMusicSection` - Add `VideoDescriptionHeader` - Add `Factoid` - Add `ExpandableVideoDescriptionBody` - Add `AdsEngagementPanelContent` - Add `engagement_panels` to raw and parsed next responses - Add `engagement_panels` parsing code to `parser.ts` * Check for song inside of video_lockup first before checking info_rows * Add support for pulling artist ids out of music_tracks - Add support for WRITERS InfoRow - Check for video id inside of naviagation endpoint on info_row metadata * Add `AdsEngagementPanelContent` to ignore list * Switch `map => parseItem` to `parseArray` * Use `Text` && `NavigationEndpoint` * Replace `String` with `Text` in `ExpandableVideoDescriptionBody` --- src/parser/classes/CarouselLockup.ts | 20 ++++++ .../classes/EngagementPanelSectionList.ts | 20 ++++++ .../classes/ExpandableVideoDescriptionBody.ts | 22 ++++++ src/parser/classes/Factoid.ts | 17 +++++ src/parser/classes/InfoRow.ts | 38 ++++++++++ .../classes/StructuredDescriptionContent.ts | 16 +++++ src/parser/classes/VideoDescriptionHeader.ts | 29 ++++++++ .../classes/VideoDescriptionMusicSection.ts | 16 +++++ src/parser/nodes.ts | 8 +++ src/parser/parser.ts | 12 +++- src/parser/types/ParsedResponse.ts | 6 +- src/parser/types/RawResponse.ts | 3 +- src/parser/youtube/VideoInfo.ts | 69 ++++++++++--------- 13 files changed, 240 insertions(+), 36 deletions(-) create mode 100644 src/parser/classes/CarouselLockup.ts create mode 100644 src/parser/classes/EngagementPanelSectionList.ts create mode 100644 src/parser/classes/ExpandableVideoDescriptionBody.ts create mode 100644 src/parser/classes/Factoid.ts create mode 100644 src/parser/classes/InfoRow.ts create mode 100644 src/parser/classes/StructuredDescriptionContent.ts create mode 100644 src/parser/classes/VideoDescriptionHeader.ts create mode 100644 src/parser/classes/VideoDescriptionMusicSection.ts diff --git a/src/parser/classes/CarouselLockup.ts b/src/parser/classes/CarouselLockup.ts new file mode 100644 index 00000000..781cf31c --- /dev/null +++ b/src/parser/classes/CarouselLockup.ts @@ -0,0 +1,20 @@ +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; + video_lockup?: CompactVideo; + + constructor(data: RawNode) { + super(); + this.info_rows = Parser.parseArray(data.infoRows, InfoRow); + const video_lockup = Parser.parseItem(data.videoLockup, CompactVideo); + if (video_lockup != null) { + this.video_lockup = video_lockup; + } + } +} diff --git a/src/parser/classes/EngagementPanelSectionList.ts b/src/parser/classes/EngagementPanelSectionList.ts new file mode 100644 index 00000000..fcdb7656 --- /dev/null +++ b/src/parser/classes/EngagementPanelSectionList.ts @@ -0,0 +1,20 @@ +import { YTNode } from '../helpers.js'; +import Parser, { type RawNode } from '../index.js'; +import ContinuationItem from './ContinuationItem.js'; +import SectionList from './SectionList.js'; +import StructuredDescriptionContent from './StructuredDescriptionContent.js'; + +export default class EngagementPanelSectionList extends YTNode { + static type = 'EngagementPanelSectionList'; + + target_id: String; + content?: SectionList|ContinuationItem|StructuredDescriptionContent; + constructor(data: RawNode) { + super(); + this.target_id = data.targetId; + const content = Parser.parseItem(data.content, [ SectionList, ContinuationItem, StructuredDescriptionContent ]); + if (content !== null) { + this.content = content; + } + } +} diff --git a/src/parser/classes/ExpandableVideoDescriptionBody.ts b/src/parser/classes/ExpandableVideoDescriptionBody.ts new file mode 100644 index 00000000..e83b9aa0 --- /dev/null +++ b/src/parser/classes/ExpandableVideoDescriptionBody.ts @@ -0,0 +1,22 @@ +import { YTNode } from '../helpers.js'; +import { type RawNode } from '../index.js'; +import { Text } from '../misc.js'; + +export default class ExpandableVideoDescriptionBody extends YTNode { + static type = 'ExpandableVideoDescriptionBody'; + + show_more_text: Text; + show_less_text: Text; + attributed_description_body_text: { + content: String + }; + + constructor(data: RawNode) { + super(); + this.show_more_text = new Text(data.showMoreText); + this.show_less_text = new Text(data.showLessText); + this.attributed_description_body_text = { + content: data.attributedDescriptionBodyText.content + }; + } +} diff --git a/src/parser/classes/Factoid.ts b/src/parser/classes/Factoid.ts new file mode 100644 index 00000000..7b73ac55 --- /dev/null +++ b/src/parser/classes/Factoid.ts @@ -0,0 +1,17 @@ +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; + } +} diff --git a/src/parser/classes/InfoRow.ts b/src/parser/classes/InfoRow.ts new file mode 100644 index 00000000..c81f971d --- /dev/null +++ b/src/parser/classes/InfoRow.ts @@ -0,0 +1,38 @@ +import { YTNode } from '../helpers.js'; +import Parser, { type RawNode } from '../index.js'; +import { Text } from '../misc.js'; +import NavigationEndpoint from './NavigationEndpoint.js'; + +export default class InfoRow extends YTNode { + static type = 'InfoRow'; + metadata_text?: Text; + metadata_endpoint?: NavigationEndpoint; + info_row_expand_status_key: String; + title: Text; + + constructor(data: RawNode) { + super(); + if ('defaultMetadata' in data && 'runs' in data.defaultMetadata) { + const runs = data.defaultMetadata.runs; + if (runs.length > 0) { + const run = runs[0]; + this.metadata_text = run?.text; + if ('navigationEndpoint' in run) { + this.metadata_endpoint = Parser.parseItem({ navigationEndpoint: run.navigationEndpoint }, NavigationEndpoint) || undefined; + } + } + } + if ('expandedMetadata' in data && 'runs' in data.expandedMetadata) { + this.metadata_text = new Text(data.expandedMetadata); + } + if (this.metadata_text === undefined) { + this.metadata_text = data.expandedMetadata?.simpleText + ? new Text(data.expandedMetadata) + : data.defaultMetadata?.simpleText + ? new Text(data.defaultMetadata) + : undefined; + } + this.info_row_expand_status_key = data.infoRowExpandStatusKey; + this.title = new Text(data.title); + } +} diff --git a/src/parser/classes/StructuredDescriptionContent.ts b/src/parser/classes/StructuredDescriptionContent.ts new file mode 100644 index 00000000..0b1b391b --- /dev/null +++ b/src/parser/classes/StructuredDescriptionContent.ts @@ -0,0 +1,16 @@ +import { type ObservedArray, YTNode } from '../helpers.js'; +import Parser, { type RawNode } from '../index.js'; +import ExpandableVideoDescriptionBody from './ExpandableVideoDescriptionBody.js'; +import VideoDescriptionHeader from './VideoDescriptionHeader.js'; +import VideoDescriptionMusicSection from './VideoDescriptionMusicSection.js'; + +export default class StructuredDescriptionContent extends YTNode { + static type = 'StructuredDescriptionContent'; + + items: ObservedArray; + + constructor(data: RawNode) { + super(); + this.items = Parser.parseArray(data.items, [ VideoDescriptionHeader, ExpandableVideoDescriptionBody, VideoDescriptionMusicSection ]); + } +} diff --git a/src/parser/classes/VideoDescriptionHeader.ts b/src/parser/classes/VideoDescriptionHeader.ts new file mode 100644 index 00000000..5bb341e5 --- /dev/null +++ b/src/parser/classes/VideoDescriptionHeader.ts @@ -0,0 +1,29 @@ +import { type ObservedArray, YTNode } from '../helpers.js'; +import Parser, { type RawNode } from '../index.js'; +import { Text } from '../misc.js'; +import type { Thumbnail } from '../misc.js'; +import Factoid from './Factoid.js'; +import NavigationEndpoint from './NavigationEndpoint.js'; + +export default class VideoDescriptionHeader extends YTNode { + static type = 'VideoDescriptionHeader'; + + channel: Text; + channel_navigation_endpoint?: NavigationEndpoint; + channel_thumbnails: String[]; + factoids: ObservedArray; + 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 = Parser.parseItem(data.channelNavigationEndpoint, NavigationEndpoint) || undefined; + this.channel_thumbnails = data.channelThumbnail.thumbnails.map((thumbnail: Thumbnail) => thumbnail.url); + this.publish_date = new Text(data.publishDate); + this.views = new Text(data.views); + this.factoids = Parser.parseArray(data.factoid, Factoid); + } +} diff --git a/src/parser/classes/VideoDescriptionMusicSection.ts b/src/parser/classes/VideoDescriptionMusicSection.ts new file mode 100644 index 00000000..681cf2c2 --- /dev/null +++ b/src/parser/classes/VideoDescriptionMusicSection.ts @@ -0,0 +1,16 @@ +import { type ObservedArray, YTNode } from '../helpers.js'; +import CarouselLockup from './CarouselLockup.js'; +import Parser, { type RawNode } from '../index.js'; +import { Text } from '../misc.js'; + +export default class VideoDescriptionMusicSection extends YTNode { + static type = 'VideoDescriptionMusicSection'; + + carousel_lockups: ObservedArray; + section_title: Text; + constructor(data: RawNode) { + super(); + this.carousel_lockups = Parser.parseArray(data.carouselLockups, CarouselLockup); + this.section_title = new Text(data.sectionTitle); + } +} diff --git a/src/parser/nodes.ts b/src/parser/nodes.ts index 4487fa8b..3cf68240 100644 --- a/src/parser/nodes.ts +++ b/src/parser/nodes.ts @@ -30,6 +30,7 @@ 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'; @@ -88,9 +89,12 @@ 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 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 FeedFilterChipBar } from './classes/FeedFilterChipBar.js'; export { default as FeedTabbedHeader } from './classes/FeedTabbedHeader.js'; export { default as GameCard } from './classes/GameCard.js'; @@ -121,6 +125,7 @@ export { default as HorizontalMovieList } from './classes/HorizontalMovieList.js export { default as IconLink } from './classes/IconLink.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'; @@ -299,6 +304,7 @@ 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 SortFilterSubMenu } from './classes/SortFilterSubMenu.js'; +export { default as StructuredDescriptionContent } from './classes/StructuredDescriptionContent.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'; @@ -335,6 +341,8 @@ 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 VideoCard } from './classes/VideoCard.js'; +export { default as VideoDescriptionHeader } from './classes/VideoDescriptionHeader.js'; +export { default as VideoDescriptionMusicSection } from './classes/VideoDescriptionMusicSection.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'; diff --git a/src/parser/parser.ts b/src/parser/parser.ts index 05ef069d..5801bdbf 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -264,6 +264,13 @@ export default class Parser { parsed_data.cards = cards; } + const engagement_panels = data.engagementPanels?.map((e) => { + const item = this.parseItem(e, YTNodes.EngagementPanelSectionList) as YTNodes.EngagementPanelSectionList; + return item; + }); + if (engagement_panels) { + parsed_data.engagement_panels = engagement_panels; + } this.#createMemo(); const items = this.parse(data.items); if (items) { @@ -502,7 +509,8 @@ export default class Parser { 'BrandVideoShelf', 'BrandVideoSingleton', 'StatementBanner', - 'GuideSigninPromo' + 'GuideSigninPromo', + 'AdsEngagementPanelContent' ]); static shouldIgnore(classname: string) { @@ -739,4 +747,4 @@ export class LiveChatContinuation extends YTNode { this.viewer_name = data.viewerName; } -} \ No newline at end of file +} diff --git a/src/parser/types/ParsedResponse.ts b/src/parser/types/ParsedResponse.ts index eedc1a24..9bcd6b18 100644 --- a/src/parser/types/ParsedResponse.ts +++ b/src/parser/types/ParsedResponse.ts @@ -17,7 +17,7 @@ import type VideoDetails from '../classes/misc/VideoDetails.js'; import type Alert from '../classes/Alert.js'; import type NavigationEndpoint from '../classes/NavigationEndpoint.js'; import type PlayerAnnotationsExpanded from '../classes/PlayerAnnotationsExpanded.js'; - +import type EngagementPanelSectionList from '../classes/EngagementPanelSectionList.js'; export interface IParsedResponse { actions?: SuperParsedResult; actions_memo?: Memo; @@ -73,6 +73,7 @@ export interface IParsedResponse { storyboards?: PlayerStoryboardSpec | PlayerLiveStoryboardSpec; endscreen?: Endscreen; cards?: CardCollection; + engagement_panels?: EngagementPanelSectionList[]; items?: SuperParsedResult; } @@ -111,6 +112,7 @@ export interface INextResponse { on_response_received_endpoints?: ObservedArray; on_response_received_endpoints_memo?: Memo; player_overlays?: SuperParsedResult; + engagement_panels?: EngagementPanelSectionList[]; } export interface IBrowseResponse { @@ -163,4 +165,4 @@ export interface IUpdatedMetadataResponse { export interface IGuideResponse { items: SuperParsedResult; items_memo: Memo; -} \ No newline at end of file +} diff --git a/src/parser/types/RawResponse.ts b/src/parser/types/RawResponse.ts index 92fe7ead..95182013 100644 --- a/src/parser/types/RawResponse.ts +++ b/src/parser/types/RawResponse.ts @@ -53,4 +53,5 @@ export interface IRawResponse { cards?: RawNode; items?: RawNode[]; frameworkUpdates?: any; -} \ No newline at end of file + engagementPanels: RawNode[]; +} diff --git a/src/parser/youtube/VideoInfo.ts b/src/parser/youtube/VideoInfo.ts index d7977f18..724bbaf7 100644 --- a/src/parser/youtube/VideoInfo.ts +++ b/src/parser/youtube/VideoInfo.ts @@ -33,6 +33,8 @@ import type { ObservedArray, YTNode } from '../helpers.js'; import { InnertubeError } from '../../utils/Utils.js'; import { MediaInfo } from '../../core/mixins/index.js'; +import StructuredDescriptionContent from '../classes/StructuredDescriptionContent.js'; +import { VideoDescriptionMusicSection } from '../nodes.js'; class VideoInfo extends MediaInfo { #watch_next_continuation?: ContinuationItem; @@ -336,41 +338,46 @@ class VideoInfo extends MediaInfo { /** * Get songs used in the video. */ - // TODO: this seems to be broken with the new UI, further investigation needed get music_tracks() { - /* - Const metadata = this.secondary_info?.metadata; - if (!metadata) - return []; - const songs = []; - let current_song: Record = {}; - let is_music_section = false; - for (let i = 0; i < metadata.rows.length; i++) { - const row = metadata.rows[i]; - if (row.is(MetadataRowHeader)) { - if (row.content?.toString().toLowerCase().startsWith('music')) { - is_music_section = true; - i++; // Skip the learn more link + const description_content = this.page[1]?.engagement_panels?.filter((panel) => panel.content?.is(StructuredDescriptionContent)); + if (description_content !== undefined && description_content.length > 0) { + const music_section = (description_content[0].content as StructuredDescriptionContent)?.items.filter((item: YTNode) => item?.is(VideoDescriptionMusicSection)) as VideoDescriptionMusicSection[]; + if (music_section !== undefined && music_section.length > 0) { + return music_section[0].carousel_lockups?.map((lookup) => { + let song, artist, album, license, videoId, channelId; + // If the song isn't in the video_lockup, it should be in the info_rows + song = lookup.video_lockup?.title; + // If the video id isn't in the video_lockup, it should be in the info_rows + videoId = lookup.video_lockup?.endpoint.payload.videoId; + for (let i = 0; i < lookup.info_rows.length; i++) { + const info_row = lookup.info_rows[i]; + if (info_row.info_row_expand_status_key === undefined) { + if (song === undefined) { + song = info_row.metadata_text; + if (videoId === undefined) { + videoId = info_row.metadata_endpoint?.payload; } - continue; + } else { + album = info_row.metadata_text; + } + } else { + if (info_row.info_row_expand_status_key?.indexOf('structured-description-music-section-artists-row-state-id') !== -1) { + artist = info_row.metadata_text; + if (channelId === undefined) { + channelId = info_row.metadata_endpoint?.payload?.browseId; + } + } + if (info_row.info_row_expand_status_key?.indexOf('structured-description-music-section-licenses-row-state-id') !== -1) { + license = info_row.metadata_text; + } } - if (!is_music_section) - continue; - if (row.is(MetadataRow)) - current_song[row.title?.toString().toLowerCase().replace(/ /g, '_')] = row.contents; - // TODO: this makes no sense, we continue above when - if (row.has_divider_line) { - songs.push(current_song); - current_song = {}; - } - - } - if (is_music_section) - songs.push(current_song); - return songs; - */ + } + return { song, artist, album, license, videoId, channelId }; + }); + } + } return []; } } -export default VideoInfo; \ No newline at end of file +export default VideoInfo;