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/.
This commit is contained in:
Luan
2024-06-21 19:31:40 -03:00
parent 67376afae6
commit 14c3a06d40
9 changed files with 91 additions and 38 deletions

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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<ToggleButton | MusicPlayButton | Menu> | null;
buttons: ObservedArray<DownloadButton | ToggleButton | MusicPlayButton | Button | Menu>;
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);

View File

@@ -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';

View File

@@ -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<T extends IParsedResponse = IParsedResponse>(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

View File

@@ -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<YTNode>;
actions_memo?: Memo;
contents?: SuperParsedResult<YTNode>;
@@ -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;

View File

@@ -20,6 +20,7 @@ export interface IRawPlayerConfig {
}
export interface IRawResponse {
background?: RawNode;
contents?: RawData;
onResponseReceivedActions?: RawNode[];
onResponseReceivedEndpoints?: RawNode[];

View File

@@ -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<MusicResponsiveListItem>;
sections: ObservedArray<MusicCarouselShelf>;
url: string | null;
background?: MusicThumbnail;
url?: string;
constructor(response: ApiResponse) {
this.#page = Parser.parseResponse<IBrowseResponse>(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 {

View File

@@ -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<MusicResponsiveListItem> | null;
#suggestions_continuation: string | null;
header?: MusicDetailHeader;
items?: ObservedArray<YTNode> | null;
header?: MusicDetailHeader | MusicEditablePlaylistDetailHeader;
contents?: ObservedArray<MusicResponsiveListItem>;
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<MusicCarouselShelf> {
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<ObservedArray<MusicResponsiveListItem>> {
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<MusicResponsiveListItem>, continuation: string | null }> {
const continuation = this.#suggestions_continuation || this.#page.contents_memo?.get('SectionList')?.[0].as(SectionList).continuation;
async #fetchSuggestions(): Promise<{ items: ObservedArray<MusicResponsiveListItem>, 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
};
}