diff --git a/docs/API/music.md b/docs/API/music.md index 46d14ac9..c86561dd 100644 --- a/docs/API/music.md +++ b/docs/API/music.md @@ -16,6 +16,7 @@ YouTube Music class. * [.getLyrics(video_id)](#getlyrics) * [.getUpNext(video_id)](#getupnext) * [.getRelated(video_id)](#getrelated) + * [.getRecap()](#getrecap) * [.getSearchSuggestions(query)](#getsearchsuggestions) @@ -238,6 +239,26 @@ Retrieves related content. | --- | --- | --- | | video_id | `string` | Video id | + +### getRecap() + +Retrieves your YouTube Music recap. + +**Returns:** `Promise.` + + +Methods & Getters + + +- `#getPlaylist()` + - Retrieves recap playlist. + +- `#page` + - Returns original InnerTube response (sanitized). + + + + ### getSearchSuggestions(query) diff --git a/src/core/Actions.ts b/src/core/Actions.ts index 37ca29d3..3ef3e19a 100644 --- a/src/core/Actions.ts +++ b/src/core/Actions.ts @@ -751,6 +751,7 @@ class Actions { 'FElibrary', 'FEhistory', 'FEsubscriptions', + 'FEmusic_listening_review', 'SPaccount_notifications', 'SPaccount_privacy', 'SPtime_watched' diff --git a/src/core/Music.ts b/src/core/Music.ts index f1e8c75a..6ce88a3d 100644 --- a/src/core/Music.ts +++ b/src/core/Music.ts @@ -9,6 +9,7 @@ import Library from '../parser/ytmusic/Library'; import Artist from '../parser/ytmusic/Artist'; import Album from '../parser/ytmusic/Album'; import Playlist from '../parser/ytmusic/Playlist'; +import Recap from '../parser/ytmusic/Recap'; import Parser from '../parser/index'; import { observe, YTNode } from '../parser/helpers'; @@ -118,6 +119,7 @@ class Music { if (!playlist_id.startsWith('VL')) { playlist_id = `VL${playlist_id}`; } + const response = await this.#actions.browse(playlist_id, { client: 'YTMUSIC' }); return new Playlist(response, this.#actions); } @@ -222,6 +224,15 @@ class Music { return shelves; } + async getRecap() { + const response = await this.#actions.execute('/browse', { + browseId: 'FEmusic_listening_review', + client: 'YTMUSIC_ANDROID' + }); + + return new Recap(response, this.#actions); + } + /** * Retrieves search suggestions for the given query. */ diff --git a/src/parser/classes/Element.ts b/src/parser/classes/Element.ts index f1c74c02..712d2cf0 100644 --- a/src/parser/classes/Element.ts +++ b/src/parser/classes/Element.ts @@ -11,6 +11,11 @@ class Element extends YTNode { constructor(data: any) { super(); + + if (Reflect.has(data, 'elementRenderer')) { + return Parser.parseItem(data, Element) as Element; + } + const type = data.newElement.type.componentType; this.model = Parser.parse(type?.model); diff --git a/src/parser/classes/HighlightsCarousel.ts b/src/parser/classes/HighlightsCarousel.ts new file mode 100644 index 00000000..067f5106 --- /dev/null +++ b/src/parser/classes/HighlightsCarousel.ts @@ -0,0 +1,86 @@ +import NavigationEndpoint from './NavigationEndpoint'; +import { YTNode } from '../helpers'; + +class Panel { + static type = 'Panel'; + + thumbnail: { + image: { + url: string; + width: number; + height: number; + }[]; + endpoint: NavigationEndpoint; + on_long_press_endpoint: NavigationEndpoint; + content_mode: string; + crop_options: string; + }; + + background_image: { + image: { + url: string; + width: number; + height: number; + }[]; + gradient_image: { + url: string; + width: number; + height: number; + }[]; + }; + + 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: any) { + this.thumbnail = { + image: data.thumbnail.image.sources, + 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: data.backgroundImage.image.sources, + gradient_image: data.backgroundImage.gradientImage.sources + }; + + 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); + } +} + +class HighlightsCarousel extends YTNode { + static type = 'HighlightsCarousel'; + + panels: Panel[]; + + constructor(data: any) { + super(); + this.panels = data.highlightsCarousel.panels.map((el: any) => new Panel(el)); + } +} + +export default HighlightsCarousel; \ No newline at end of file diff --git a/src/parser/classes/MusicElementHeader.ts b/src/parser/classes/MusicElementHeader.ts new file mode 100644 index 00000000..c852a639 --- /dev/null +++ b/src/parser/classes/MusicElementHeader.ts @@ -0,0 +1,17 @@ +import Parser from '../index'; +import Element from './Element'; + +import { YTNode } from '../helpers'; + +class MusicElementHeader extends YTNode { + static type = 'MusicElementHeader'; + + element: Element | null; + + constructor(data: any) { + super(); + this.element = Reflect.has(data, 'elementRenderer') ? Parser.parseItem(data, Element) : null; + } +} + +export default MusicElementHeader; \ No newline at end of file diff --git a/src/parser/classes/MusicLargeCardItemCarousel.ts b/src/parser/classes/MusicLargeCardItemCarousel.ts new file mode 100644 index 00000000..f5c6aa18 --- /dev/null +++ b/src/parser/classes/MusicLargeCardItemCarousel.ts @@ -0,0 +1,59 @@ +import NavigationEndpoint from './NavigationEndpoint'; +import { YTNode } from '../helpers'; + +class ActionButton { + static type = 'ActionButton'; + + icon_name: string; + endpoint: NavigationEndpoint; + a11y_text: string; + style: string; + + constructor(data: any) { + 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: { + url: string; + width: number; + height: number; + }[]; + + content_mode: string; + crop_options: string; + image_aspect_ratio: string; + caption: string; + action_buttons: ActionButton[]; + + constructor (data: any) { + this.image = data.image.image.sources; + 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: any) => new ActionButton(el)); + } +} + +class MusicLargeCardItemCarousel extends YTNode { + static type = 'MusicLargeCardItemCarousel'; + + panels: Panel[]; + header; + + constructor(data: any) { + super(); + // TODO: check this + this.header = data.shelf.header; + this.panels = data.shelf.panels.map((el: any) => new Panel(el)); + } +} + +export default MusicLargeCardItemCarousel; \ No newline at end of file diff --git a/src/parser/classes/NavigationEndpoint.ts b/src/parser/classes/NavigationEndpoint.ts index f05b990a..7e83761a 100644 --- a/src/parser/classes/NavigationEndpoint.ts +++ b/src/parser/classes/NavigationEndpoint.ts @@ -77,6 +77,8 @@ class NavigationEndpoint extends YTNode { 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) { @@ -236,6 +238,16 @@ class NavigationEndpoint extends YTNode { } } + /** + * 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'; + } + } + callTest(actions: Actions, args: { [ key: string ]: any; parse: true }): Promise; callTest(actions: Actions, args?: { [ key: string ]: any; parse?: false }): Promise; callTest(actions: Actions, args?: { [ key: string ]: any; parse?: boolean }): Promise { diff --git a/src/parser/map.ts b/src/parser/map.ts index 15155ef3..e378b4a2 100644 --- a/src/parser/map.ts +++ b/src/parser/map.ts @@ -1,5 +1,5 @@ // This file was auto generated, do not edit. -// See ./scripts/build-parser-json.js +// See ./scripts/build-parser-map.js import { YTNodeConstructor } from './helpers'; import { default as AccountChannel } from './classes/AccountChannel'; @@ -74,6 +74,7 @@ import { default as GridChannel } from './classes/GridChannel'; import { default as GridHeader } from './classes/GridHeader'; import { default as GridPlaylist } from './classes/GridPlaylist'; import { default as GridVideo } from './classes/GridVideo'; +import { default as HighlightsCarousel } from './classes/HighlightsCarousel'; import { default as HistorySuggestion } from './classes/HistorySuggestion'; import { default as HorizontalCardList } from './classes/HorizontalCardList'; import { default as HorizontalList } from './classes/HorizontalList'; @@ -146,10 +147,12 @@ import { default as MusicCarouselShelfBasicHeader } from './classes/MusicCarouse import { default as MusicDescriptionShelf } from './classes/MusicDescriptionShelf'; import { default as MusicDetailHeader } from './classes/MusicDetailHeader'; import { default as MusicEditablePlaylistDetailHeader } from './classes/MusicEditablePlaylistDetailHeader'; +import { default as MusicElementHeader } from './classes/MusicElementHeader'; import { default as MusicHeader } from './classes/MusicHeader'; import { default as MusicImmersiveHeader } from './classes/MusicImmersiveHeader'; import { default as MusicInlineBadge } from './classes/MusicInlineBadge'; import { default as MusicItemThumbnailOverlay } from './classes/MusicItemThumbnailOverlay'; +import { default as MusicLargeCardItemCarousel } from './classes/MusicLargeCardItemCarousel'; import { default as MusicNavigationButton } from './classes/MusicNavigationButton'; import { default as MusicPlayButton } from './classes/MusicPlayButton'; import { default as MusicPlaylistShelf } from './classes/MusicPlaylistShelf'; @@ -335,6 +338,7 @@ const map: Record = { GridHeader, GridPlaylist, GridVideo, + HighlightsCarousel, HistorySuggestion, HorizontalCardList, HorizontalList, @@ -407,10 +411,12 @@ const map: Record = { MusicDescriptionShelf, MusicDetailHeader, MusicEditablePlaylistDetailHeader, + MusicElementHeader, MusicHeader, MusicImmersiveHeader, MusicInlineBadge, MusicItemThumbnailOverlay, + MusicLargeCardItemCarousel, MusicNavigationButton, MusicPlayButton, MusicPlaylistShelf, diff --git a/src/parser/youtube/Analytics.ts b/src/parser/youtube/Analytics.ts index 1ca1f838..d40ee5b9 100644 --- a/src/parser/youtube/Analytics.ts +++ b/src/parser/youtube/Analytics.ts @@ -9,7 +9,7 @@ class Analytics { constructor(response: AxioslikeResponse) { this.#page = Parser.parseResponse(response.data); this.sections = this.#page.contents_memo?.get('Element') - ?.map((el) => el.as(Element).model.item()); + ?.map((el) => el.as(Element).model?.item()); } get page(): ParsedResponse { diff --git a/src/parser/ytmusic/Playlist.ts b/src/parser/ytmusic/Playlist.ts index 6e62c746..4588c42f 100644 --- a/src/parser/ytmusic/Playlist.ts +++ b/src/parser/ytmusic/Playlist.ts @@ -3,11 +3,14 @@ import Actions, { AxioslikeResponse } from '../../core/Actions'; import MusicCarouselShelf from '../classes/MusicCarouselShelf'; import MusicPlaylistShelf from '../classes/MusicPlaylistShelf'; -import SectionList from '../classes/SectionList'; -import { InnertubeError } from '../../utils/Utils'; import MusicEditablePlaylistDetailHeader from '../classes/MusicEditablePlaylistDetailHeader'; +import MusicDetailHeader from '../classes/MusicDetailHeader'; import MusicShelf from '../classes/MusicShelf'; +import SectionList from '../classes/SectionList'; + +import { InnertubeError } from '../../utils/Utils'; + class Playlist { #page; #actions; @@ -21,7 +24,7 @@ class Playlist { constructor(response: AxioslikeResponse, actions: Actions) { this.#actions = actions; this.#page = Parser.parseResponse(response.data); - this.#actions = actions; + this.#suggestions_continuation = this.#page.contents_memo.getType(MusicShelf)?.find( (shelf) => shelf.title.toString() === 'Suggestions')?.continuation || null; this.#last_fetched_suggestions = null; @@ -32,9 +35,9 @@ class Playlist { this.#continuation = data.continuation; } else { if (this.#page.header?.item().type === 'MusicEditablePlaylistDetailHeader') { - this.header = this.#page.header?.item().as(MusicEditablePlaylistDetailHeader).header.item(); + this.header = this.#page.header?.item().as(MusicEditablePlaylistDetailHeader).header.item().as(MusicDetailHeader); } else { - this.header = this.#page.header?.item() || null; + this.header = this.#page.header?.item().as(MusicDetailHeader) || null; } this.items = this.#page.contents_memo.getType(MusicPlaylistShelf)?.[0].contents; this.#continuation = this.#page.contents_memo.getType(MusicPlaylistShelf)?.[0].continuation || null; @@ -50,16 +53,14 @@ class Playlist { } /** - * Retrieves playlist item continuation. + * Retrieves playlist items continuation. */ async getContinuation() { - if (this.#continuation) { - const response = await this.#actions.browse(this.#continuation, { is_ctoken: true, client: 'YTMUSIC' }); - return new Playlist(response, this.#actions); - } - - throw new InnertubeError('Continuation not found.'); + if (!this.#continuation) + throw new InnertubeError('Continuation not found.'); + const response = await this.#actions.browse(this.#continuation, { is_ctoken: true, client: 'YTMUSIC' }); + return new Playlist(response, this.#actions); } /** @@ -114,7 +115,6 @@ class Playlist { continuation: null }; } - } export default Playlist; \ No newline at end of file diff --git a/src/parser/ytmusic/Recap.ts b/src/parser/ytmusic/Recap.ts new file mode 100644 index 00000000..7e6e86ed --- /dev/null +++ b/src/parser/ytmusic/Recap.ts @@ -0,0 +1,55 @@ +import Parser, { ParsedResponse } from '../index'; +import Actions, { AxioslikeResponse } from '../../core/Actions'; + +import Playlist from './Playlist'; +import MusicCarouselShelf from '../classes/MusicCarouselShelf'; +import MusicElementHeader from '../classes/MusicElementHeader'; +import HighlightsCarousel from '../classes/HighlightsCarousel'; +import SingleColumnBrowseResults from '../classes/SingleColumnBrowseResults'; + +import Tab from '../classes/Tab'; +import ItemSection from '../classes/ItemSection'; +import SectionList from '../classes/SectionList'; + +import { InnertubeError } from '../../utils/Utils'; + +class Recap { + #page; + #actions; + + header; + sections; + + constructor(response: AxioslikeResponse, actions: Actions) { + this.#page = Parser.parseResponse(response.data); + this.#actions = actions; + + this.header = this.#page.header.item().as(MusicElementHeader).element?.model?.item().as(HighlightsCarousel); + + const tab = this.#page.contents.item().as(SingleColumnBrowseResults).tabs.firstOfType(Tab); + + if (!tab) + throw new InnertubeError('Target tab not found'); + + this.sections = tab.content?.as(SectionList).contents.array().as(ItemSection, MusicCarouselShelf); + } + + /** + * Retrieves recap playlist. + */ + async getPlaylist() { + if (!this.header) + throw new InnertubeError('Header not found'); + + const endpoint = this.header.panels[0].text_on_tap_endpoint; + const response = await endpoint.callTest(this.#actions, { client: 'YTMUSIC' }); + + return new Playlist(response, this.#actions); + } + + get page(): ParsedResponse { + return this.#page; + } +} + +export default Recap; \ No newline at end of file
+ +- `#getPlaylist()` + - Retrieves recap playlist. + +- `#page` + - Returns original InnerTube response (sanitized). + +