diff --git a/src/Innertube.ts b/src/Innertube.ts index 7e57093d76..a2306c7566 100644 --- a/src/Innertube.ts +++ b/src/Innertube.ts @@ -13,6 +13,7 @@ import { HomeFeed, Library, NotificationsMenu, + PaidMemberships, Playlist, Search, VideoInfo @@ -143,7 +144,7 @@ export default class Innertube { signatureTimestamp: session.player?.signature_timestamp } }, - client: options?.client + client: options?.client }; if (options?.po_token) { @@ -155,7 +156,7 @@ export default class Innertube { poToken: session.po_token }; } - + const watch_response = await watch_endpoint.call(session.actions, extra_payload); const cpn = generateRandomString(16); @@ -296,7 +297,7 @@ export default class Innertube { 'Cookie': session.cookie || '' } }); - + const text = await response.text(); const data = JSON.parse(text.replace('window.google.ac.h(', '').slice(0, -1)); @@ -553,6 +554,45 @@ export default class Innertube { return new Comments(this.actions, response.data); } + /** + * Gets the user's paid purchases and memberships. + * + * @see {@link https://www.youtube.com/paid_memberships} + * + * @example + * ```ts + * const page = await yt.getPaidMemberships(); + * + * // Digital purchases & rentals (paginated) + * let section = page.purchases; + * while (section) { + * for (const item of section.items) { + * console.log(item.title?.text?.toString()); + * } + * try { + * section = await section.getContinuation(); + * } catch { + * break; + * } + * } + * + * // Active memberships + * for (const m of page.memberships) { + * console.log(m.channel_name, m.membership_level, m.price); + * } + * + * // Inactive memberships + * for (const m of page.inactive_memberships) { + * console.log(m.channel_name, m.expired_date); + * } + * ``` + */ + async getPaidMemberships(): Promise { + const browse_endpoint = new NavigationEndpoint({ browseEndpoint: { browseId: 'FEmemberships_and_purchases' } }); + const response = await browse_endpoint.call(this.#session.actions); + return await PaidMemberships.create(this.actions, response); + } + /** * Fetches an attestation challenge. */ @@ -560,10 +600,10 @@ export default class Innertube { const payload: Record = { engagementType: engagement_type }; - + if (ids) payload.ids = ids; - + return this.actions.execute('/att/get', { parse: true, ...payload }); } diff --git a/src/parser/classes/ActivityItem.ts b/src/parser/classes/ActivityItem.ts new file mode 100644 index 0000000000..75c592e656 --- /dev/null +++ b/src/parser/classes/ActivityItem.ts @@ -0,0 +1,29 @@ +import { YTNode } from '../helpers.js'; +import type { RawNode } from '../index.js'; +import { NavigateAction, Parser } from '../index.js'; +import CardItemText from './CardItemText.js'; +import CardItemTextCollection from './CardItemTextCollection.js'; +import ThemedImage from './ThemedImage.js'; + +export default class ActivityItem extends YTNode { + static type = 'ActivityItem'; + + image: ThemedImage | null; + title: CardItemText | null; + subtitle: CardItemText | null; + section_heading: CardItemText | null; + metadata: CardItemTextCollection | null; + on_tap: NavigateAction | null; + + constructor(data: RawNode) { + super(); + this.image = Parser.parseItem(data.image, ThemedImage); + this.title = Parser.parseItem(data.title, CardItemText); + this.subtitle = Parser.parseItem(data.subtitle, CardItemText); + this.section_heading = Parser.parseItem(data.sectionHeading, CardItemText); + this.metadata = Parser.parseItem(data.activityMetadata, CardItemTextCollection); + this.on_tap = data.onTap + ? new NavigateAction(data.onTap.navigateAction) + : null; + } +} diff --git a/src/parser/classes/CardItem.ts b/src/parser/classes/CardItem.ts new file mode 100644 index 0000000000..050fddd335 --- /dev/null +++ b/src/parser/classes/CardItem.ts @@ -0,0 +1,19 @@ +import CardItemActions from './CardItemActions.js'; +import CardItemTextWithImage from './CardItemTextWithImage.js'; +import CardItemTextCollection from './CardItemTextCollection.js'; +import { YTNode } from '../helpers.js'; +import { Parser } from '../index.js'; +import type { RawNode } from '../index.js'; + +export default class CardItem extends YTNode { + static type = 'CardItem'; + + heading: CardItemTextWithImage | CardItemTextCollection | null; + additional_info: CardItemActions | null; + + constructor(data: RawNode) { + super(); + this.heading = Parser.parseItem(data.headingRenderer, [ CardItemTextWithImage, CardItemTextCollection ]); + this.additional_info = Parser.parseItem(data.additionalInfoRenderer, CardItemActions); + } +} diff --git a/src/parser/classes/CardItemActions.ts b/src/parser/classes/CardItemActions.ts new file mode 100644 index 0000000000..a4c9f50843 --- /dev/null +++ b/src/parser/classes/CardItemActions.ts @@ -0,0 +1,14 @@ +import { YTNode } from '../helpers.js'; +import { Parser } from '../index.js'; +import type { RawNode } from '../index.js'; + +export default class CardItemActions extends YTNode { + static type = 'CardItemActions'; + + primary_button: YTNode | null; + + constructor(data: RawNode) { + super(); + this.primary_button = Parser.parseItem(data.primaryButtonRenderer); + } +} diff --git a/src/parser/classes/CardItemContainer.ts b/src/parser/classes/CardItemContainer.ts new file mode 100644 index 0000000000..edeb142964 --- /dev/null +++ b/src/parser/classes/CardItemContainer.ts @@ -0,0 +1,22 @@ +import type { ObservedArray } from '../helpers.js'; +import { YTNode } from '../helpers.js'; +import type { RawNode } from '../index.js'; +import { Parser } from '../index.js'; +import NavigationEndpoint from './NavigationEndpoint.js'; + +export default class CardItemContainer extends YTNode { + static type = 'CardItemContainer'; + + contents: ObservedArray; + base_renderer: YTNode | null; + endpoint: NavigationEndpoint | null; + target_id: string | null; + + constructor(data: RawNode) { + super(); + this.contents = Parser.parseArray(data.contents); + this.base_renderer = Parser.parseItem(data.baseRenderer); + this.endpoint = new NavigationEndpoint(data.onClickCommand); + this.target_id = data.targetId || null; + } +} diff --git a/src/parser/classes/CardItemText.ts b/src/parser/classes/CardItemText.ts new file mode 100644 index 0000000000..b5158ff926 --- /dev/null +++ b/src/parser/classes/CardItemText.ts @@ -0,0 +1,18 @@ +import { YTNode } from '../helpers.js'; +import type { RawNode } from '../index.js'; +import Text from './misc/Text.js'; + +export default class CardItemText extends YTNode { + static type = 'CardItemText'; + + text: Text; + textColor: string; + style: string; + + constructor(data: RawNode) { + super(); + this.text = new Text(data.text); + this.textColor = data.textColor; + this.style = data.style; + } +} diff --git a/src/parser/classes/CardItemTextCollection.ts b/src/parser/classes/CardItemTextCollection.ts new file mode 100644 index 0000000000..ff55e88ca6 --- /dev/null +++ b/src/parser/classes/CardItemTextCollection.ts @@ -0,0 +1,17 @@ +import CardItemText from './CardItemText.js'; +import { YTNode } from '../helpers.js'; +import { Parser } from '../index.js'; +import type { RawNode } from '../index.js'; + +export default class CardItemTextCollection extends YTNode { + static type = 'CardItemTextCollection'; + + texts: CardItemText[]; + + constructor(data: RawNode) { + super(); + this.texts = (data.textRenderers || []).map( + (renderer: RawNode) => Parser.parseItem(renderer, CardItemText) + ); + } +} diff --git a/src/parser/classes/CardItemTextWithImage.ts b/src/parser/classes/CardItemTextWithImage.ts new file mode 100644 index 0000000000..fb30215ca6 --- /dev/null +++ b/src/parser/classes/CardItemTextWithImage.ts @@ -0,0 +1,25 @@ +import CardItemTextCollection from './CardItemTextCollection.js'; +import type Thumbnail from './misc/Thumbnail.js'; +import ThemedImage from './ThemedImage.js'; +import { YTNode } from '../helpers.js'; +import { Parser } from '../index.js'; +import type { RawNode } from '../index.js'; + +export default class CardItemTextWithImage extends YTNode { + static type = 'CardItemTextWithImage'; + + image: ThemedImage | null; + thumbnails: Thumbnail[]; + text_collections: CardItemTextCollection[]; + + constructor(data: RawNode) { + super(); + + this.image = Parser.parseItem(data.imageRenderer, ThemedImage); + this.thumbnails = this.image?.thumbnails || []; + + this.text_collections = (data.textCollectionRenderer || []).map( + (collection: RawNode) => Parser.parseItem(collection, CardItemTextCollection) + ); + } +} diff --git a/src/parser/classes/OfferItem.ts b/src/parser/classes/OfferItem.ts new file mode 100644 index 0000000000..b3fcd4e843 --- /dev/null +++ b/src/parser/classes/OfferItem.ts @@ -0,0 +1,20 @@ +import CardItemTextCollection from './CardItemTextCollection.js'; +import ThemedImage from './ThemedImage.js'; +import { YTNode } from '../helpers.js'; +import { Parser } from '../index.js'; +import type { RawNode } from '../index.js'; + +export default class OfferItem extends YTNode { + static type = 'OfferItem'; + + image: ThemedImage | null; + heading: CardItemTextCollection | null; + description: CardItemTextCollection | null; + + constructor(data: RawNode) { + super(); + this.image = Parser.parseItem(data.imageRenderer, ThemedImage); + this.heading = Parser.parseItem(data.headingRenderer, CardItemTextCollection); + this.description = Parser.parseItem(data.descriptionRenderer, CardItemTextCollection); + } +} diff --git a/src/parser/classes/ThemedImage.ts b/src/parser/classes/ThemedImage.ts new file mode 100644 index 0000000000..d88fc6cef3 --- /dev/null +++ b/src/parser/classes/ThemedImage.ts @@ -0,0 +1,20 @@ +import { YTNode } from '../helpers.js'; +import type { RawNode } from '../index.js'; +import Thumbnail from './misc/Thumbnail.js'; + +export default class ThemedImage extends YTNode { + static type = 'ThemedImage'; + + thumbnails: Thumbnail[]; + image_height: number; + image_width: number; + is_circular: boolean; + + constructor(data: RawNode) { + super(); + this.thumbnails = Thumbnail.fromResponse({ thumbnails: data.imageLight?.thumbnails || [] }); + this.image_height = data.imageHeight; + this.image_width = data.imageWidth; + this.is_circular = data.isCircular; + } +} diff --git a/src/parser/classes/actions/UpdateCardItemOnClickCommand.ts b/src/parser/classes/actions/UpdateCardItemOnClickCommand.ts new file mode 100644 index 0000000000..1b17839ce1 --- /dev/null +++ b/src/parser/classes/actions/UpdateCardItemOnClickCommand.ts @@ -0,0 +1,17 @@ +import NavigationEndpoint from '../NavigationEndpoint.js'; +import { YTNode } from '../../helpers.js'; +import { Parser } from '../../index.js'; +import type { RawNode } from '../../index.js'; + +export default class UpdateCardItemOnClickCommand extends YTNode { + static type = 'UpdateCardItemOnClickCommand'; + + endpoint: NavigationEndpoint; + target_id: string; + + constructor(data: RawNode) { + super(); + this.endpoint = new NavigationEndpoint(data.onClickCommand); + this.target_id = data.targetId; + } +} diff --git a/src/parser/continuations.ts b/src/parser/continuations.ts index fc289b7bc4..2dd8e8139f 100644 --- a/src/parser/continuations.ts +++ b/src/parser/continuations.ts @@ -46,6 +46,7 @@ export class ShowMiniplayerCommand extends YTNode { } export { default as AppendContinuationItemsAction } from './classes/actions/AppendContinuationItemsAction.js'; +export { default as UpdateCardItemOnClickCommand } from './classes/actions/UpdateCardItemOnClickCommand.js'; export class ReloadContinuationItemsCommand extends YTNode { static readonly type = 'reloadContinuationItemsCommand'; diff --git a/src/parser/nodes.ts b/src/parser/nodes.ts index 09a759bcf7..66325fb649 100644 --- a/src/parser/nodes.ts +++ b/src/parser/nodes.ts @@ -14,10 +14,12 @@ export { default as GetMultiPageMenuAction } from './classes/actions/GetMultiPag export { default as OpenPopupAction } from './classes/actions/OpenPopupAction.js'; export { default as SendFeedbackAction } from './classes/actions/SendFeedbackAction.js'; export { default as SignalAction } from './classes/actions/SignalAction.js'; +export { default as UpdateCardItemOnClickCommand } from './classes/actions/UpdateCardItemOnClickCommand.js'; export { default as UpdateChannelSwitcherPageAction } from './classes/actions/UpdateChannelSwitcherPageAction.js'; export { default as UpdateEngagementPanelAction } from './classes/actions/UpdateEngagementPanelAction.js'; export { default as UpdateSubscribeButtonAction } from './classes/actions/UpdateSubscribeButtonAction.js'; export { default as ActiveAccountHeader } from './classes/ActiveAccountHeader.js'; +export { default as ActivityItem } from './classes/ActivityItem.js'; export { default as AddToPlaylist } from './classes/AddToPlaylist.js'; export { default as Alert } from './classes/Alert.js'; export { default as AlertWithButton } from './classes/AlertWithButton.js'; @@ -41,6 +43,12 @@ export { default as C4TabbedHeader } from './classes/C4TabbedHeader.js'; export { default as CallToActionButton } from './classes/CallToActionButton.js'; export { default as Card } from './classes/Card.js'; export { default as CardCollection } from './classes/CardCollection.js'; +export { default as CardItem } from './classes/CardItem.js'; +export { default as CardItemActions } from './classes/CardItemActions.js'; +export { default as CardItemContainer } from './classes/CardItemContainer.js'; +export { default as CardItemText } from './classes/CardItemText.js'; +export { default as CardItemTextCollection } from './classes/CardItemTextCollection.js'; +export { default as CardItemTextWithImage } from './classes/CardItemTextWithImage.js'; export { default as CarouselHeader } from './classes/CarouselHeader.js'; export { default as CarouselItem } from './classes/CarouselItem.js'; export { default as CarouselItemView } from './classes/CarouselItemView.js'; @@ -361,6 +369,7 @@ export { default as TopbarMenuButton } from './classes/mweb/TopbarMenuButton.js' export { default as NavigationEndpoint } from './classes/NavigationEndpoint.js'; export { default as Notification } from './classes/Notification.js'; export { default as NotificationAction } from './classes/NotificationAction.js'; +export { default as OfferItem } from './classes/OfferItem.js'; export { default as OpenOnePickAddVideoModalCommand } from './classes/OpenOnePickAddVideoModalCommand.js'; export { default as PageHeader } from './classes/PageHeader.js'; export { default as PageHeaderView } from './classes/PageHeaderView.js'; @@ -476,6 +485,7 @@ export { default as TabbedSearchResults } from './classes/TabbedSearchResults.js export { default as TextCarouselItemView } from './classes/TextCarouselItemView.js'; export { default as TextFieldView } from './classes/TextFieldView.js'; export { default as TextHeader } from './classes/TextHeader.js'; +export { default as ThemedImage } from './classes/ThemedImage.js'; export { default as ThirdPartyShareTargetSection } from './classes/ThirdPartyShareTargetSection.js'; export { default as ThumbnailBadgeView } from './classes/ThumbnailBadgeView.js'; export { default as ThumbnailBottomOverlayView } from './classes/ThumbnailBottomOverlayView.js'; diff --git a/src/parser/parser.ts b/src/parser/parser.ts index dec8a1cceb..42e69c403e 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -41,7 +41,7 @@ import CommentView from './classes/comments/CommentView.js'; import MusicThumbnail from './classes/MusicThumbnail.js'; import OpenPopupAction from './classes/actions/OpenPopupAction.js'; import AppendContinuationItemsAction from './classes/actions/AppendContinuationItemsAction.js'; -import type { IParsedResponse, IRawResponse, RawData, RawNode } from './types/index.js'; +import type { IParsedResponse, IRawResponse, RawData, RawNode, ResponseReceived } from './types/index.js'; const TAG = 'Parser'; @@ -482,13 +482,13 @@ export function parseResponse(data: if (engagement_panels.length) { parsed_data.engagement_panels = engagement_panels; } - + if (data.bgChallenge) { const interpreter_url = { private_do_not_access_or_else_trusted_resource_url_wrapped_value: data.bgChallenge.interpreterUrl.privateDoNotAccessOrElseTrustedResourceUrlWrappedValue, private_do_not_access_or_else_safe_script_wrapped_value: data.bgChallenge.interpreterUrl.privateDoNotAccessOrElseSafeScriptWrappedValue }; - + parsed_data.bg_challenge = { interpreter_url, interpreter_hash: data.bgChallenge.interpreterHash, @@ -497,7 +497,7 @@ export function parseResponse(data: client_experiments_state_blob: data.bgChallenge.clientExperimentsStateBlob }; } - + if (data.challenge) { parsed_data.challenge = data.challenge; } @@ -520,7 +520,7 @@ export function parseResponse(data: if (data.entries) { parsed_data.entries = data.entries.map((entry) => new NavigationEndpoint(entry)); } - + if (data.targetId) { parsed_data.target_id = data.targetId; } @@ -750,9 +750,11 @@ export function parseRR(actions: RawNode[]) { return new ReloadContinuationItemsCommand(action.reloadContinuationItemsCommand); else if (action.appendContinuationItemsAction) return new AppendContinuationItemsAction(action.appendContinuationItemsAction); + else if (action.updateCardItemOnClickCommand) + return new YTNodes.UpdateCardItemOnClickCommand(action.updateCardItemOnClickCommand); else if (action.openPopupAction) return new OpenPopupAction(action.openPopupAction); - }).filter((item) => item) as (AppendContinuationItemsAction | OpenPopupAction | NavigateAction | ShowMiniplayerCommand | ReloadContinuationItemsCommand)[]); + }).filter((item) => item) as ResponseReceived[]); } export function parseActions(data: RawData) { diff --git a/src/parser/types/ParsedResponse.ts b/src/parser/types/ParsedResponse.ts index 25f6547e48..040568566d 100644 --- a/src/parser/types/ParsedResponse.ts +++ b/src/parser/types/ParsedResponse.ts @@ -2,7 +2,8 @@ import type { Memo, ObservedArray, SuperParsedResult, YTNode } from '../helpers. import type { ReloadContinuationItemsCommand, Continuation, GridContinuation, ItemSectionContinuation, LiveChatContinuation, MusicPlaylistShelfContinuation, MusicShelfContinuation, - PlaylistPanelContinuation, SectionListContinuation, ContinuationCommand, ShowMiniplayerCommand, NavigateAction + PlaylistPanelContinuation, SectionListContinuation, ContinuationCommand, ShowMiniplayerCommand, NavigateAction, + UpdateCardItemOnClickCommand } from '../index.js'; import type PlayerCaptionsTracklist from '../classes/PlayerCaptionsTracklist.js'; @@ -22,6 +23,14 @@ import type AppendContinuationItemsAction from '../classes/actions/AppendContinu import type MusicThumbnail from '../classes/MusicThumbnail.js'; import type OpenPopupAction from '../classes/actions/OpenPopupAction.js'; +export type ResponseReceived = + | AppendContinuationItemsAction + | OpenPopupAction + | NavigateAction + | ShowMiniplayerCommand + | ReloadContinuationItemsCommand + | UpdateCardItemOnClickCommand; + export interface IParsedResponse { background?: MusicThumbnail; challenge?: string; @@ -37,11 +46,11 @@ export interface IParsedResponse { live_chat_item_context_menu_supported_renderers?: YTNode; live_chat_item_context_menu_supported_renderers_memo?: Memo; items_memo?: Memo; - on_response_received_actions?: ObservedArray; + on_response_received_actions?: ObservedArray; on_response_received_actions_memo?: Memo; - on_response_received_endpoints?: ObservedArray; + on_response_received_endpoints?: ObservedArray; on_response_received_endpoints_memo?: Memo; - on_response_received_commands?: ObservedArray; + on_response_received_commands?: ObservedArray; on_response_received_commands_memo?: Memo; continuation?: Continuation; continuation_contents?: ItemSectionContinuation | SectionListContinuation | LiveChatContinuation | MusicPlaylistShelfContinuation | diff --git a/src/parser/youtube/PaidMemberships.ts b/src/parser/youtube/PaidMemberships.ts new file mode 100644 index 0000000000..439d07626a --- /dev/null +++ b/src/parser/youtube/PaidMemberships.ts @@ -0,0 +1,178 @@ +import Button from '../classes/Button.js'; +import CardItem from '../classes/CardItem.js'; +import type CardItemContainer from '../classes/CardItemContainer.js'; +import CardItemTextCollection from '../classes/CardItemTextCollection.js'; +import CardItemTextWithImage from '../classes/CardItemTextWithImage.js'; +import ItemSection from '../classes/ItemSection.js'; +import type Thumbnail from '../classes/misc/Thumbnail.js'; +import OfferItem from '../classes/OfferItem.js'; +import { Parser } from '../index.js'; +import PaidPurchasesSection from './PaidPurchasesSection.js'; + +import type { Actions, ApiResponse } from '../../core/index.js'; +import type { IParsedResponse } from '../types/index.js'; + +export interface ChannelMembership { + channel_name: string; + channel_thumbnails: Thumbnail[]; + membership_level: string; + price?: string; + expired_date?: string; +} + +export default class PaidMemberships { + readonly #page: IParsedResponse; + readonly #actions: Actions; + + public purchases: PaidPurchasesSection | null; + public memberships: ChannelMembership[]; + public inactive_memberships: ChannelMembership[]; + + private constructor(actions: Actions, page: IParsedResponse, purchases: PaidPurchasesSection | null) { + this.#actions = actions; + this.#page = page; + this.purchases = purchases; + this.memberships = []; + this.inactive_memberships = []; + } + + /** + * Creates a PaidMemberships instance by analysing the response layout. + */ + static async create(actions: Actions, response: ApiResponse): Promise { + const page = Parser.parseResponse(response.data); + const sections = page.contents_memo?.getType(ItemSection) || []; + + // "Offers from YouTube" always exists — lone section means nothing to parse + if (sections.length <= 1) { + return new PaidMemberships(actions, page, null); + } + + let purchases_section: ItemSection | undefined; + let membership_section: ItemSection | undefined; + + for (const section of sections) { + // "Offers from YouTube" — contains OfferItem, skip it + if (section.contents?.some((node) => node.is(OfferItem))) { + continue; + } + + const first_card = section.contents?.find((node) => node.is(CardItem))?.as(CardItem); + if (!first_card) { + continue; + } + + if (first_card.heading instanceof CardItemTextWithImage) { + purchases_section = section; + } else if (first_card.heading instanceof CardItemTextCollection) { + membership_section = section; + } + } + + const instance = new PaidMemberships( + actions, + page, + purchases_section + ? new PaidPurchasesSection(actions, response, purchases_section) + : null + ); + + // Resolve active/inactive within the membership section + if (membership_section) { + const { active, inactive } = await PaidMemberships.#resolveActiveInactive(actions, membership_section); + instance.memberships.push(...PaidMemberships.#parseMembershipCards(active, true)); + instance.inactive_memberships.push(...PaidMemberships.#parseMembershipCards(inactive, false)); + } + + return instance; + } + + /** + * Splits a membership section's contents into active and inactive groups. + * + * Max 2 CardItem headers act as boundary markers — everything else is CardItemContainer. + * - 1 CardItem: single block, endpoint Button count determines active vs inactive. + * - 2 CardItems: split at the second header; first group's endpoint determines which is active. + * - 0 CardItems: unexpected, returns empty. + */ + static async #resolveActiveInactive(actions: Actions, section: ItemSection): Promise<{ + active: CardItemContainer[]; + inactive: CardItemContainer[]; + }> { + const items = section.contents || []; + const titleIndexes = items.reduce((pv, cv, idx) => { + if (cv.is(CardItem)) + pv.push(idx); + return pv; + }, [] as number[]); + + if (titleIndexes.length === 2) { + const arr = [ ...items ] as CardItemContainer[]; + const active = arr.splice(1, titleIndexes[1] - 1); + arr.shift(); + arr.shift(); + const inactive = arr; + return { active, inactive }; + } + + if (titleIndexes.length === 1) { + const arr = [ ...items ] as CardItemContainer[]; + arr.shift(); + const result = await arr[0]?.endpoint?.call(actions, { parse: true }); + const buttonCount = result?.on_response_received_actions_memo?.getType(Button)?.length || 0; + const isActive = buttonCount > 1; + const active = isActive ? arr : []; + const inactive = isActive ? [] : arr; + return { active, inactive }; + } + + return { active: [], inactive: [] }; + } + + static #parseMembershipCards(containers: CardItemContainer[], is_active: boolean): ChannelMembership[] { + const result: ChannelMembership[] = []; + + for (const container of containers) { + const card = container.base_renderer?.as(CardItem); + if (!card) { + continue; + } + + const heading = card.heading; + + if (heading instanceof CardItemTextWithImage) { + const texts = heading.text_collections.flatMap((c) => c.texts); + + // When no membership level: [name, extra] + // With membership level: [name, level, extra] + const has_level = texts.length >= 3; + const second_text = texts[1]?.text?.toString() || ''; + const third_text = texts[2]?.text?.toString() || ''; + + const channel_name = texts[0]?.text?.toString() || ''; + const membership_level = has_level ? second_text : ''; + const extra = has_level ? third_text : second_text; + + const data: ChannelMembership = { + channel_name, + channel_thumbnails: heading.thumbnails, + membership_level + }; + + if (is_active) { + data.price = extra; + } else { + data.expired_date = extra; + } + + result.push(data); + } + } + + return result; + } + + get page(): IParsedResponse { + return this.#page; + } +} diff --git a/src/parser/youtube/PaidPurchasesSection.ts b/src/parser/youtube/PaidPurchasesSection.ts new file mode 100644 index 0000000000..bc0ced33c2 --- /dev/null +++ b/src/parser/youtube/PaidPurchasesSection.ts @@ -0,0 +1,72 @@ +import { InnertubeError } from '../../utils/Utils.js'; +import ActivityItem from '../classes/ActivityItem.js'; +import CardItemContainer from '../classes/CardItemContainer.js'; +import { Parser, UpdateCardItemOnClickCommand } from '../index.js'; + +import type { Actions, ApiResponse } from '../../core/index.js'; +import type { IParsedResponse } from '../types/index.js'; +import type ItemSection from '../classes/ItemSection.js'; + +export default class PaidPurchasesSection { + readonly #page: IParsedResponse; + readonly #actions: Actions; + readonly #container: CardItemContainer | null; + + public items: ActivityItem[]; + + constructor(actions: Actions, response: ApiResponse, purchases_section?: ItemSection) { + this.#actions = actions; + this.#page = Parser.parseResponse(response.data); + + // Continuation response: onResponseReceivedActions + if (this.#page.on_response_received_actions_memo) { + this.items = this.#page.on_response_received_actions_memo.getType(ActivityItem); + this.#container = null; + return; + } + + // Initial browse response: use the pre-identified section + if (purchases_section) { + this.#container = purchases_section.contents + ?.find((node) => node.is(CardItemContainer)) + ?.as(CardItemContainer) || null; + } else { + this.#container = null; + } + + this.items = this.#container?.contents?.filterType(ActivityItem) || []; + } + + /** + * Loads the next page of purchases, or throws if no continuation is present. + */ + async getContinuation(): Promise { + const endpoint = this.#getContinuationEndpoint(); + + if (!endpoint) { + throw new InnertubeError('Continuation not found'); + } + + const response = await endpoint.call(this.#actions, { parse: false }); + + return new PaidPurchasesSection(this.#actions, response); + } + + #getContinuationEndpoint() { + // Continuation response: next token from UpdateCardItemOnClickCommand + if (this.#page.on_response_received_actions) { + for (const action of this.#page.on_response_received_actions) { + if (action.is(UpdateCardItemOnClickCommand)) { + return action.as(UpdateCardItemOnClickCommand)?.endpoint; + } + } + } + + // Initial browse response: endpoint on the stored container + return this.#container?.endpoint; + } + + get page(): IParsedResponse { + return this.#page; + } +} diff --git a/src/parser/youtube/index.ts b/src/parser/youtube/index.ts index e2c094f299..53ae89137f 100644 --- a/src/parser/youtube/index.ts +++ b/src/parser/youtube/index.ts @@ -10,6 +10,8 @@ export { default as ItemMenu } from './ItemMenu.js'; export { default as Library } from './Library.js'; export { default as LiveChat } from './LiveChat.js'; export { default as NotificationsMenu } from './NotificationsMenu.js'; +export { default as PaidMemberships } from './PaidMemberships.js'; +export { default as PaidPurchasesSection } from './PaidPurchasesSection.js'; export { default as Playlist } from './Playlist.js'; export { default as Search } from './Search.js'; export { default as Settings } from './Settings.js'; diff --git a/src/utils/index.ts b/src/utils/index.ts index affa7df52d..88738e5e0e 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -9,7 +9,7 @@ export * as FormatUtils from './FormatUtils.js'; export { default as HTTPClient } from './HTTPClient.js'; export * from './HTTPClient.js'; -export { Platform } from './Utils.js'; +export { InnertubeError, Platform } from './Utils.js'; export * as Utils from './Utils.js'; export * as Log from './Log.js';