diff --git a/src/core/Actions.ts b/src/core/Actions.ts index 7e6fd448..d795496c 100644 --- a/src/core/Actions.ts +++ b/src/core/Actions.ts @@ -6,9 +6,10 @@ import Parser, { ParsedResponse } from '../parser/index'; import { hasKeys, InnertubeError, MissingParamError, uuidv4 } from '../utils/Utils'; export interface BrowseArgs { - params?: string; + params?: string | null; is_ytm?: boolean; is_ctoken?: boolean; + form_data?: {}; client?: string; } @@ -94,6 +95,10 @@ class Actions { data.browseId = id; } + if (args.form_data) { + data.formData = args.form_data; + } + if (args.client) { data.client = args.client; } diff --git a/src/parser/classes/MusicDetailHeader.ts b/src/parser/classes/MusicDetailHeader.ts index 20f1bec6..af731731 100644 --- a/src/parser/classes/MusicDetailHeader.ts +++ b/src/parser/classes/MusicDetailHeader.ts @@ -19,7 +19,7 @@ class MusicDetailHeader extends YTNode { badges; author?: { name: string; - channel_id: string; + channel_id: string | undefined; endpoint: NavigationEndpoint | undefined; }; menu; diff --git a/src/parser/classes/MusicShelf.ts b/src/parser/classes/MusicShelf.ts index 4f72f996..5e9bc8ae 100644 --- a/src/parser/classes/MusicShelf.ts +++ b/src/parser/classes/MusicShelf.ts @@ -13,6 +13,7 @@ class MusicShelf extends YTNode { endpoint: NavigationEndpoint | null; continuation: string | null; bottom_text: Text | null; + subheaders?: Array; constructor(data: any) { super(); @@ -24,6 +25,9 @@ class MusicShelf extends YTNode { data.continuations?.[0].nextContinuationData?.continuation || data.continuations?.[0].reloadContinuationData?.continuation || null; this.bottom_text = Reflect.has(data, 'bottomText') ? new Text(data.bottomText) : null; + if (data.subheaders) { + this.subheaders = Parser.parseArray(data.subheaders); + } } } diff --git a/src/parser/classes/MusicSideAlignedItem.ts b/src/parser/classes/MusicSideAlignedItem.ts new file mode 100644 index 00000000..601645d8 --- /dev/null +++ b/src/parser/classes/MusicSideAlignedItem.ts @@ -0,0 +1,19 @@ +import Parser from '../index'; + +import { YTNode } from '../helpers'; + +class MusicSideAlignedItem extends YTNode { + static type = 'MusicSideAlignedItem'; + + start_items?; + + constructor(data: any) { + super(); + + if (data.startItems) { + this.start_items = Parser.parseArray(data.startItems); + } + } +} + +export default MusicSideAlignedItem; \ No newline at end of file diff --git a/src/parser/classes/MusicSortFilterButton.ts b/src/parser/classes/MusicSortFilterButton.ts new file mode 100644 index 00000000..98879faf --- /dev/null +++ b/src/parser/classes/MusicSortFilterButton.ts @@ -0,0 +1,23 @@ +import Parser from '../index'; + +import { YTNode } from '../helpers'; +import MusicMultiSelectMenu from './menus/MusicMultiSelectMenu'; +import Text from './misc/Text'; + +class MusicSortFilterButton extends YTNode { + static type = 'MusicSortFilterButton'; + + title: string; + icon_type: string; + menu: MusicMultiSelectMenu | null; + + constructor(data: any) { + super(); + + this.title = new Text(data.title).text; + this.icon_type = data.icon?.icon_type || null; + this.menu = Parser.parseItem(data.menu, MusicMultiSelectMenu); + } +} + +export default MusicSortFilterButton; \ No newline at end of file diff --git a/src/parser/classes/MusicTwoRowItem.ts b/src/parser/classes/MusicTwoRowItem.ts index e8e434e4..69db4536 100644 --- a/src/parser/classes/MusicTwoRowItem.ts +++ b/src/parser/classes/MusicTwoRowItem.ts @@ -25,13 +25,13 @@ class MusicTwoRowItem extends YTNode { artists?: { name: string; - channel_id: string; + channel_id: string | undefined; endpoint: NavigationEndpoint | undefined; }[]; author?: { name: string; - channel_id: string; + channel_id: string | undefined; endpoint: NavigationEndpoint | undefined; }; diff --git a/src/parser/classes/NavigationEndpoint.ts b/src/parser/classes/NavigationEndpoint.ts index 3160685a..bc1b13ef 100644 --- a/src/parser/classes/NavigationEndpoint.ts +++ b/src/parser/classes/NavigationEndpoint.ts @@ -18,7 +18,13 @@ class NavigationEndpoint extends YTNode { }; // TODO: these should be given proper types, currently infered - browse; + browse?: { + id: string, + params: string | null, + base_url: string | null, + page_type: string | null, + form_data?: {} + }; watch; search; subscribe; diff --git a/src/parser/classes/menus/MusicMenuItemDivider.ts b/src/parser/classes/menus/MusicMenuItemDivider.ts new file mode 100644 index 00000000..218f0578 --- /dev/null +++ b/src/parser/classes/menus/MusicMenuItemDivider.ts @@ -0,0 +1,12 @@ +import { YTNode } from '../../helpers'; + +class MusicMenuItemDivider extends YTNode { + static type = 'MusicMenuItemDivider'; + + // eslint-disable-next-line + constructor(data: any) { + super(); + } +} + +export default MusicMenuItemDivider; \ No newline at end of file diff --git a/src/parser/classes/menus/MusicMultiSelectMenu.ts b/src/parser/classes/menus/MusicMultiSelectMenu.ts new file mode 100644 index 00000000..71eafd92 --- /dev/null +++ b/src/parser/classes/menus/MusicMultiSelectMenu.ts @@ -0,0 +1,21 @@ +import MusicMultiSelectMenuItem from './MusicMultiSelectMenuItem'; +import MusicMenuItemDivider from './MusicMenuItemDivider'; +import { YTNode } from '../../helpers'; +import Text from '../misc/Text'; +import Parser from '../..'; + +class MusicMultiSelectMenu extends YTNode { + static type = 'MusicMultiSelectMenu'; + + title: string; + options: Array; + + constructor(data: any) { + super(); + + this.title = new Text(data.title.musicMenuTitleRenderer?.primaryText).text; + this.options = Parser.parseArray(data.options, [ MusicMultiSelectMenuItem, MusicMenuItemDivider ]); + } +} + +export default MusicMultiSelectMenu; \ No newline at end of file diff --git a/src/parser/classes/menus/MusicMultiSelectMenuItem.ts b/src/parser/classes/menus/MusicMultiSelectMenuItem.ts new file mode 100644 index 00000000..299d2757 --- /dev/null +++ b/src/parser/classes/menus/MusicMultiSelectMenuItem.ts @@ -0,0 +1,37 @@ +import { YTNode } from '../../helpers'; +import Text from '../misc/Text'; +import NavigationEndpoint from '../NavigationEndpoint'; + +class MusicMultiSelectMenuItem extends YTNode { + static type = 'MusicMultiSelectMenuItem'; + + title: string; + form_item_entity_key: string; + selected_icon_type: string; + endpoint?: NavigationEndpoint; + selected: boolean; + + constructor(data: any) { + super(); + + this.title = new Text(data.title).text; + this.form_item_entity_key = data.formItemEntityKey; + this.selected_icon_type = data.selectedIcon?.iconType || null; + const command = data.selectedCommand?.commandExecutorCommand?.commands?.find((command: any) => command.musicBrowseFormBinderCommand?.browseEndpoint); + if (command) { + /** + * At this point, endpoint will still be missing `form_data` field which is required for + * selection to take effect. This can only be obtained from the response data which + * we don't have here. We shall delegate this task back to `Parser`. + */ + this.endpoint = new NavigationEndpoint(command.musicBrowseFormBinderCommand); + } + /** + * Inferring selected state from existence of endpoint. `Parser` shall + * update this with the definitive value obtained from response data. + */ + this.selected = !!this.endpoint; + } +} + +export default MusicMultiSelectMenuItem; \ No newline at end of file diff --git a/src/parser/index.ts b/src/parser/index.ts index 207b3c3a..984cba97 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -10,6 +10,7 @@ import { InnertubeError, ParsingError } from '../utils/Utils'; import { YTNode, YTNodeConstructor, SuperParsedResult, ObservedArray, observe, Memo } from './helpers'; import package_json from '../../package.json'; +import MusicMultiSelectMenuItem from './classes/menus/MusicMultiSelectMenuItem'; export class AppendContinuationItemsAction extends YTNode { static readonly type = 'appendContinuationItemsAction'; @@ -223,6 +224,8 @@ export default class Parser { const actions_memo = this.#getMemo(); this.#clearMemo(); + this.applyMutations(contents_memo, data.frameworkUpdates?.entityBatchUpdate?.mutations); + return { actions, actions_memo, @@ -389,6 +392,44 @@ export default class Parser { return new SuperParsedResult(this.parseItem(data, validTypes)); } + static applyMutations(memo: Memo, mutations: Array) { + // Apply mutations to MusicMultiSelectMenuItems + const musicMultiSelectMenuItems = memo.getType(MusicMultiSelectMenuItem); + if (musicMultiSelectMenuItems.length > 0 && !mutations) { + console.warn( + new InnertubeError( + 'Mutation data required for processing MusicMultiSelectMenuItems, but none found.\n' + + `This is a bug, please report it at ${package_json.bugs.url}` + ) + ); + } else { + const missingOrInvalidMutations = []; + for (const menuItem of musicMultiSelectMenuItems) { + const mutation = mutations.find((mutation) => mutation.payload?.musicFormBooleanChoice?.id === menuItem.form_item_entity_key); + const choice = mutation?.payload.musicFormBooleanChoice; + if (choice?.selected !== undefined && choice?.opaqueToken) { + menuItem.selected = choice.selected; + if (menuItem.endpoint?.browse) { + menuItem.endpoint.browse.form_data = { + selectedValues: [ choice.opaqueToken ] + }; + } + } else { + missingOrInvalidMutations.push(`'${menuItem.title}'`); + } + } + if (missingOrInvalidMutations.length > 0) { + console.warn( + new InnertubeError( + `Mutation data missing or invalid for ${missingOrInvalidMutations.length} out of ${musicMultiSelectMenuItems.length} MusicMultiSelectMenuItems. ` + + `The titles of the failed items are: ${missingOrInvalidMutations.join(', ')}.\n` + + `This is a bug, please report it at ${package_json.bugs.url}` + ) + ); + } + } + } + static formatError({ classname, classdata, err }: { classname: string, classdata: any, err: any }) { if (err.code == 'MODULE_NOT_FOUND') { return console.warn( diff --git a/src/parser/map.ts b/src/parser/map.ts index 8dcd5aa7..0eadb28d 100644 --- a/src/parser/map.ts +++ b/src/parser/map.ts @@ -124,6 +124,9 @@ import { default as MenuServiceItem } from './classes/menus/MenuServiceItem'; import { default as MenuServiceItemDownload } from './classes/menus/MenuServiceItemDownload'; import { default as MultiPageMenu } from './classes/menus/MultiPageMenu'; import { default as MultiPageMenuNotificationSection } from './classes/menus/MultiPageMenuNotificationSection'; +import { default as MusicMenuItemDivider } from './classes/menus/MusicMenuItemDivider'; +import { default as MusicMultiSelectMenu } from './classes/menus/MusicMultiSelectMenu'; +import { default as MusicMultiSelectMenuItem } from './classes/menus/MusicMultiSelectMenuItem'; import { default as SimpleMenuHeader } from './classes/menus/SimpleMenuHeader'; import { default as MerchandiseItem } from './classes/MerchandiseItem'; import { default as MerchandiseShelf } from './classes/MerchandiseShelf'; @@ -153,6 +156,8 @@ import { default as MusicResponsiveListItem } from './classes/MusicResponsiveLis import { default as MusicResponsiveListItemFixedColumn } from './classes/MusicResponsiveListItemFixedColumn'; import { default as MusicResponsiveListItemFlexColumn } from './classes/MusicResponsiveListItemFlexColumn'; import { default as MusicShelf } from './classes/MusicShelf'; +import { default as MusicSideAlignedItem } from './classes/MusicSideAlignedItem'; +import { default as MusicSortFilterButton } from './classes/MusicSortFilterButton'; import { default as MusicThumbnail } from './classes/MusicThumbnail'; import { default as MusicTwoRowItem } from './classes/MusicTwoRowItem'; import { default as NavigationEndpoint } from './classes/NavigationEndpoint'; @@ -372,6 +377,9 @@ const map: Record = { MenuServiceItemDownload, MultiPageMenu, MultiPageMenuNotificationSection, + MusicMenuItemDivider, + MusicMultiSelectMenu, + MusicMultiSelectMenuItem, SimpleMenuHeader, MerchandiseItem, MerchandiseShelf, @@ -401,6 +409,8 @@ const map: Record = { MusicResponsiveListItemFixedColumn, MusicResponsiveListItemFlexColumn, MusicShelf, + MusicSideAlignedItem, + MusicSortFilterButton, MusicThumbnail, MusicTwoRowItem, NavigationEndpoint,