mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-06-25 07:42:11 +00:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
28
src/parser/classes/MusicPlaylistEditHeader.ts
Normal file
28
src/parser/classes/MusicPlaylistEditHeader.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface IRawPlayerConfig {
|
||||
}
|
||||
|
||||
export interface IRawResponse {
|
||||
background?: RawNode;
|
||||
contents?: RawData;
|
||||
onResponseReceivedActions?: RawNode[];
|
||||
onResponseReceivedEndpoints?: RawNode[];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user