From 14c3a06d402989e98a9d32c79b2dc26f74fb0219 Mon Sep 17 00:00:00 2001 From: Luan Date: Fri, 21 Jun 2024 19:31:40 -0300 Subject: [PATCH] fix(YTMusic): Add support for new header layouts This is due to a minor page redesign by YouTube Music. See https://9to5google.com/2024/06/20/youtube-music-web-album-playlist-redesign/. --- .../MusicEditablePlaylistDetailHeader.ts | 6 +- src/parser/classes/MusicPlaylistEditHeader.ts | 28 +++++++++ src/parser/classes/MusicResponsiveHeader.ts | 6 +- src/parser/nodes.ts | 1 + src/parser/parser.ts | 6 ++ src/parser/types/ParsedResponse.ts | 4 +- src/parser/types/RawResponse.ts | 1 + src/parser/ytmusic/Album.ts | 20 ++++--- src/parser/ytmusic/Playlist.ts | 57 +++++++++++-------- 9 files changed, 91 insertions(+), 38 deletions(-) create mode 100644 src/parser/classes/MusicPlaylistEditHeader.ts diff --git a/src/parser/classes/MusicEditablePlaylistDetailHeader.ts b/src/parser/classes/MusicEditablePlaylistDetailHeader.ts index 7658c032..dbcfbbcf 100644 --- a/src/parser/classes/MusicEditablePlaylistDetailHeader.ts +++ b/src/parser/classes/MusicEditablePlaylistDetailHeader.ts @@ -5,11 +5,13 @@ 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); - - // TODO: Parse data.editHeader.musicPlaylistEditHeaderRenderer. + this.edit_header = Parser.parseItem(data.editHeader); + this.playlist_id = data.playlistId; } } \ No newline at end of file diff --git a/src/parser/classes/MusicPlaylistEditHeader.ts b/src/parser/classes/MusicPlaylistEditHeader.ts new file mode 100644 index 00000000..763a5bc3 --- /dev/null +++ b/src/parser/classes/MusicPlaylistEditHeader.ts @@ -0,0 +1,28 @@ +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); + } +} \ No newline at end of file diff --git a/src/parser/classes/MusicResponsiveHeader.ts b/src/parser/classes/MusicResponsiveHeader.ts index 2e8a7e5e..ba50f3fb 100644 --- a/src/parser/classes/MusicResponsiveHeader.ts +++ b/src/parser/classes/MusicResponsiveHeader.ts @@ -7,6 +7,8 @@ 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'; @@ -14,7 +16,7 @@ export default class MusicResponsiveHeader extends YTNode { static type = 'MusicResponsiveHeader'; thumbnail: MusicThumbnail | null; - buttons: ObservedArray | null; + buttons: ObservedArray; title: Text; subtitle: Text; strapline_text_one: Text; @@ -26,7 +28,7 @@ export default class MusicResponsiveHeader extends YTNode { constructor(data: RawNode) { super(); this.thumbnail = Parser.parseItem(data.thumbnail, MusicThumbnail); - this.buttons = Parser.parseArray(data.buttons, [ ToggleButton, MusicPlayButton, Menu ]); + 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); diff --git a/src/parser/nodes.ts b/src/parser/nodes.ts index 41f40e90..46d2ad1b 100644 --- a/src/parser/nodes.ts +++ b/src/parser/nodes.ts @@ -261,6 +261,7 @@ export { default as MusicLargeCardItemCarousel } from './classes/MusicLargeCardI 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'; diff --git a/src/parser/parser.ts b/src/parser/parser.ts index 848e920e..296f9f1e 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -26,6 +26,7 @@ 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'; @@ -367,6 +368,11 @@ export function parseResponse(data: 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 diff --git a/src/parser/types/ParsedResponse.ts b/src/parser/types/ParsedResponse.ts index 2fc6e83a..e7b99766 100644 --- a/src/parser/types/ParsedResponse.ts +++ b/src/parser/types/ParsedResponse.ts @@ -19,9 +19,10 @@ 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 } from '../nodes.js'; +import type { AppendContinuationItemsAction, MusicThumbnail } from '../nodes.js'; export interface IParsedResponse { + background?: MusicThumbnail; actions?: SuperParsedResult; actions_memo?: Memo; contents?: SuperParsedResult; @@ -134,6 +135,7 @@ export interface INextResponse { } export interface IBrowseResponse { + background?: MusicThumbnail; continuation_contents?: ItemSectionContinuation | SectionListContinuation | LiveChatContinuation | MusicPlaylistShelfContinuation | MusicShelfContinuation | GridContinuation | PlaylistPanelContinuation; continuation_contents_memo?: Memo; diff --git a/src/parser/types/RawResponse.ts b/src/parser/types/RawResponse.ts index 6dac01a0..dae7e34e 100644 --- a/src/parser/types/RawResponse.ts +++ b/src/parser/types/RawResponse.ts @@ -20,6 +20,7 @@ export interface IRawPlayerConfig { } export interface IRawResponse { + background?: RawNode; contents?: RawData; onResponseReceivedActions?: RawNode[]; onResponseReceivedEndpoints?: RawNode[]; diff --git a/src/parser/ytmusic/Album.ts b/src/parser/ytmusic/Album.ts index 93f9483b..7ba82221 100644 --- a/src/parser/ytmusic/Album.ts +++ b/src/parser/ytmusic/Album.ts @@ -3,33 +3,35 @@ import { Parser } from '../index.js'; import MicroformatData from '../classes/MicroformatData.js'; import MusicCarouselShelf from '../classes/MusicCarouselShelf.js'; import MusicDetailHeader from '../classes/MusicDetailHeader.js'; +import MusicResponsiveHeader from '../classes/MusicResponsiveHeader.js'; import MusicShelf from '../classes/MusicShelf.js'; +import type MusicThumbnail from '../classes/MusicThumbnail.js'; import type { ApiResponse } from '../../core/index.js'; -import type { ObservedArray } from '../helpers.js'; +import { observe, type ObservedArray } from '../helpers.js'; import type { IBrowseResponse } from '../types/index.js'; import type MusicResponsiveListItem from '../classes/MusicResponsiveListItem.js'; class Album { #page: IBrowseResponse; - header?: MusicDetailHeader; + header?: MusicDetailHeader | MusicResponsiveHeader; contents: ObservedArray; sections: ObservedArray; - - url: string | null; + background?: MusicThumbnail; + url?: string; constructor(response: ApiResponse) { this.#page = Parser.parseResponse(response.data); - this.header = this.#page.header?.item().as(MusicDetailHeader); - this.url = this.#page.microformat?.as(MicroformatData).url_canonical || null; - if (!this.#page.contents_memo) throw new Error('No contents found in the response'); - this.contents = this.#page.contents_memo.getType(MusicShelf)?.first().contents; - this.sections = this.#page.contents_memo.getType(MusicCarouselShelf) || []; + this.header = this.#page.contents_memo.getType(MusicDetailHeader, MusicResponsiveHeader)?.first(); + this.contents = this.#page.contents_memo.getType(MusicShelf)?.first().contents || observe([]); + this.sections = this.#page.contents_memo.getType(MusicCarouselShelf) || observe([]); + this.background = this.#page.background; + this.url = this.#page.microformat?.as(MicroformatData).url_canonical; } get page(): IBrowseResponse { diff --git a/src/parser/ytmusic/Playlist.ts b/src/parser/ytmusic/Playlist.ts index ccbab14e..afdc255d 100644 --- a/src/parser/ytmusic/Playlist.ts +++ b/src/parser/ytmusic/Playlist.ts @@ -6,22 +6,24 @@ import MusicEditablePlaylistDetailHeader from '../classes/MusicEditablePlaylistD import MusicPlaylistShelf from '../classes/MusicPlaylistShelf.js'; import MusicShelf from '../classes/MusicShelf.js'; import SectionList from '../classes/SectionList.js'; +import MusicResponsiveListItem from '../classes/MusicResponsiveListItem.js'; +import MusicThumbnail from '../classes/MusicThumbnail.js'; import { InnertubeError } from '../../utils/Utils.js'; -import type { ObservedArray, YTNode } from '../helpers.js'; +import { observe, type ObservedArray } from '../helpers.js'; import type { ApiResponse, Actions } from '../../core/index.js'; import type { IBrowseResponse } from '../types/index.js'; -import type MusicResponsiveListItem from '../classes/MusicResponsiveListItem.js'; class Playlist { #page: IBrowseResponse; #actions: Actions; #continuation: string | null; - #last_fetched_suggestions: any; - #suggestions_continuation: any; + #last_fetched_suggestions: ObservedArray | null; + #suggestions_continuation: string | null; - header?: MusicDetailHeader; - items?: ObservedArray | null; + header?: MusicDetailHeader | MusicEditablePlaylistDetailHeader; + contents?: ObservedArray; + background?: MusicThumbnail; constructor(response: ApiResponse, actions: Actions) { this.#actions = actions; @@ -32,16 +34,17 @@ class Playlist { if (this.#page.continuation_contents) { const data = this.#page.continuation_contents?.as(MusicPlaylistShelfContinuation); - this.items = data.contents; + if (!data.contents) + throw new InnertubeError('No contents found in the response'); + this.contents = data.contents.as(MusicResponsiveListItem); this.#continuation = data.continuation; } else { - if (this.#page.header?.item().type === 'MusicEditablePlaylistDetailHeader') { - this.header = this.#page.header?.item().as(MusicEditablePlaylistDetailHeader).header?.as(MusicDetailHeader); - } else { - this.header = this.#page.header?.item().as(MusicDetailHeader); - } - this.items = this.#page.contents_memo?.getType(MusicPlaylistShelf).first().contents || null; - this.#continuation = this.#page.contents_memo?.getType(MusicPlaylistShelf).first().continuation || null; + if (!this.#page.contents_memo) + throw new InnertubeError('No contents found in the response'); + this.header = this.#page.contents_memo.getType(MusicEditablePlaylistDetailHeader, MusicDetailHeader)?.first(); + this.contents = this.#page.contents_memo.getType(MusicPlaylistShelf)?.first()?.contents || observe([]); + this.background = this.#page.background; + this.#continuation = this.#page.contents_memo.getType(MusicPlaylistShelf)?.first()?.continuation || null; } } @@ -64,7 +67,12 @@ class Playlist { * Retrieves related playlists */ async getRelated(): Promise { - let section_continuation = this.#page.contents_memo?.getType(SectionList)?.[0].continuation; + const target_section_list = this.#page.contents_memo?.getType(SectionList).find((section_list) => section_list.continuation); + + if (!target_section_list) + throw new InnertubeError('Could not find "Related" section.'); + + let section_continuation = target_section_list.continuation; while (section_continuation) { const data = await this.#actions.execute('/browse', { @@ -76,7 +84,7 @@ class Playlist { const section_list = data.continuation_contents?.as(SectionListContinuation); const sections = section_list?.contents?.as(MusicCarouselShelf, MusicShelf); - const related = sections?.matchCondition((section) => section.is(MusicCarouselShelf))?.as(MusicCarouselShelf); + const related = sections?.find((section) => section.is(MusicCarouselShelf))?.as(MusicCarouselShelf); if (related) return related; @@ -84,10 +92,10 @@ class Playlist { section_continuation = section_list?.continuation; } - throw new InnertubeError('Target section not found.'); + throw new InnertubeError('Could not fetch related playlists.'); } - async getSuggestions(refresh = true) { + async getSuggestions(refresh = true): Promise> { const require_fetch = refresh || !this.#last_fetched_suggestions; const fetch_promise = require_fetch ? this.#fetchSuggestions() : Promise.resolve(null); const fetch_result = await fetch_promise; @@ -97,11 +105,12 @@ class Playlist { this.#suggestions_continuation = fetch_result.continuation; } - return fetch_result?.items || this.#last_fetched_suggestions; + return fetch_result?.items || this.#last_fetched_suggestions || observe([]); } - async #fetchSuggestions(): Promise<{ items: never[] | ObservedArray, continuation: string | null }> { - const continuation = this.#suggestions_continuation || this.#page.contents_memo?.get('SectionList')?.[0].as(SectionList).continuation; + async #fetchSuggestions(): Promise<{ items: ObservedArray, continuation: string | null }> { + const target_section_list = this.#page.contents_memo?.getType(SectionList).find((section_list) => section_list.continuation); + const continuation = this.#suggestions_continuation || target_section_list?.continuation; if (continuation) { const page = await this.#actions.execute('/browse', { @@ -113,16 +122,16 @@ class Playlist { const section_list = page.continuation_contents?.as(SectionListContinuation); const sections = section_list?.contents?.as(MusicCarouselShelf, MusicShelf); - const suggestions = sections?.matchCondition((section) => section.is(MusicShelf))?.as(MusicShelf); + const suggestions = sections?.find((section) => section.is(MusicShelf))?.as(MusicShelf); return { - items: suggestions?.contents || [], + items: suggestions?.contents || observe([]), continuation: suggestions?.continuation || null }; } return { - items: [], + items: observe([]), continuation: null }; }