diff --git a/src/core/Actions.ts b/src/core/Actions.ts index 4bc88a7a..b1375f26 100644 --- a/src/core/Actions.ts +++ b/src/core/Actions.ts @@ -580,7 +580,7 @@ class Actions { /** * Mostly used for pagination and specific operations. */ - async next(args: { video_id?: string; ctoken?: string; client?: string; } = {}) { + async next(args: { video_id?: string; ctoken?: string; client?: string; playlist_id?: string; params?: string } = {}) { const data: Record = { client: args.client }; if (args.ctoken) { @@ -591,6 +591,14 @@ class Actions { data.videoId = args.video_id; } + if (args.playlist_id) { + data.playlistId = args.playlist_id; + } + + if (args.params) { + data.params = args.params; + } + const response = await this.#session.http.fetch('/next', { method: 'POST', body: JSON.stringify(data), diff --git a/src/core/Music.ts b/src/core/Music.ts index c060196c..9fb52915 100644 --- a/src/core/Music.ts +++ b/src/core/Music.ts @@ -77,10 +77,8 @@ class Music { /** * Retrieves the Library. */ - async getLibrary() { - const response = await this.#actions.browse('FEmusic_liked_albums', { client: 'YTMUSIC' }); - return new Library(response); - // TODO: return new Library(response, this.#actions); + getLibrary() { + return new Library(this.#actions); } /** diff --git a/src/parser/classes/AutomixPreviewVideo.ts b/src/parser/classes/AutomixPreviewVideo.ts new file mode 100644 index 00000000..dde29a86 --- /dev/null +++ b/src/parser/classes/AutomixPreviewVideo.ts @@ -0,0 +1,19 @@ +import { YTNode } from '../helpers'; +import NavigationEndpoint from './NavigationEndpoint'; + +class AutomixPreviewVideo extends YTNode { + static type = 'AutomixPreviewVideo'; + + playlist_video?: { endpoint: NavigationEndpoint }; + + constructor(data: any) { + super(); + if (data?.content?.automixPlaylistVideoRenderer?.navigationEndpoint) { + this.playlist_video = { + endpoint: new NavigationEndpoint(data.content.automixPlaylistVideoRenderer.navigationEndpoint) + }; + } + } +} + +export default AutomixPreviewVideo; \ No newline at end of file diff --git a/src/parser/classes/CreatePlaylistDialog.ts b/src/parser/classes/CreatePlaylistDialog.ts new file mode 100644 index 00000000..8fcff0ff --- /dev/null +++ b/src/parser/classes/CreatePlaylistDialog.ts @@ -0,0 +1,27 @@ +import Parser from '..'; +import { ObservedArray, YTNode } from '../helpers'; +import Button from './Button'; +import Dropdown from './Dropdown'; +import DropdownItem from './DropdownItem'; +import Text from './misc/Text'; + +class CreatePlaylistDialog extends YTNode { + static type = 'CreatePlaylistDialog'; + + title: string; + title_placeholder: string; + privacy_option: ObservedArray | null; + cancel_button: Button | null; + create_button: Button | null; + + constructor(data: any) { + super(); + this.title = new Text(data.dialogTitle).toString(); + this.title_placeholder = data.titlePlaceholder || ''; + this.privacy_option = Parser.parseItem(data.privacyOption, Dropdown)?.entries || null; + this.create_button = Parser.parseItem(data.cancelButton); + this.cancel_button = Parser.parseItem(data.cancelButton); + } +} + +export default CreatePlaylistDialog; \ No newline at end of file diff --git a/src/parser/classes/Dropdown.ts b/src/parser/classes/Dropdown.ts new file mode 100644 index 00000000..278651a9 --- /dev/null +++ b/src/parser/classes/Dropdown.ts @@ -0,0 +1,19 @@ +import Parser from '..'; +import { ObservedArray, YTNode } from '../helpers'; +import DropdownItem from './DropdownItem'; + +class Dropdown extends YTNode { + static type = 'Dropdown'; + + label: string; + entries: ObservedArray; + + constructor(data: any) { + super(); + + this.label = data.label || ''; + this.entries = Parser.parseArray(data.entries, DropdownItem); + } +} + +export default Dropdown; \ No newline at end of file diff --git a/src/parser/classes/DropdownItem.ts b/src/parser/classes/DropdownItem.ts new file mode 100644 index 00000000..80e221c4 --- /dev/null +++ b/src/parser/classes/DropdownItem.ts @@ -0,0 +1,34 @@ +import { YTNode } from '../helpers'; +import Text from './misc/Text'; +import NavigationEndpoint from './NavigationEndpoint'; + +class DropdownItem extends YTNode { + static type = 'DropdownItem'; + + label: string; + selected: boolean; + value?: number; + iconType?: string; + endpoint?: NavigationEndpoint; + + constructor(data: any) { + super(); + + this.label = new Text(data.label).toString(); + this.selected = !!data.isSelected; + + if (data.int32Value) { + this.value = data.int32Value; + } + + if (data.onSelectCommand?.browseEndpoint) { + this.endpoint = new NavigationEndpoint(data.onSelectCommand); + } + + if (data.icon?.iconType) { + this.iconType = data.icon?.iconType; + } + } +} + +export default DropdownItem; \ No newline at end of file diff --git a/src/parser/classes/Grid.js b/src/parser/classes/Grid.js index ed710659..d158050e 100644 --- a/src/parser/classes/Grid.js +++ b/src/parser/classes/Grid.js @@ -10,6 +10,7 @@ class Grid extends YTNode { this.is_collapsible = data.isCollapsible; this.visible_row_count = data.visibleRowCount; this.target_id = data.targetId; + this.continuation = data.continuations?.[0]?.nextContinuationData?.continuation || null; } // XXX: alias for consistency diff --git a/src/parser/classes/ItemSection.ts b/src/parser/classes/ItemSection.ts index 822552f3..06415a43 100644 --- a/src/parser/classes/ItemSection.ts +++ b/src/parser/classes/ItemSection.ts @@ -2,17 +2,18 @@ import Parser from '../index'; import ItemSectionHeader from './ItemSectionHeader'; import { YTNode } from '../helpers'; +import ItemSectionTabbedHeader from './ItemSectionTabbedHeader'; class ItemSection extends YTNode { static type = 'ItemSection'; - header: ItemSectionHeader | null; + header: ItemSectionHeader | ItemSectionTabbedHeader | null; contents; target_id; constructor(data: any) { super(); - this.header = Parser.parseItem(data.header, ItemSectionHeader); + this.header = Parser.parseItem(data.header, [ ItemSectionHeader, ItemSectionTabbedHeader ]); this.contents = Parser.parse(data.contents, true); if (data.targetId || data.sectionIdentifier) { diff --git a/src/parser/classes/ItemSectionTab.ts b/src/parser/classes/ItemSectionTab.ts new file mode 100644 index 00000000..1c610913 --- /dev/null +++ b/src/parser/classes/ItemSectionTab.ts @@ -0,0 +1,21 @@ +import NavigationEndpoint from './NavigationEndpoint'; + +import { YTNode } from '../helpers'; +import Text from './misc/Text'; + +class ItemSectionTab extends YTNode { + static type = 'Tab'; + + title: Text; + selected: boolean; + endpoint: NavigationEndpoint; + + constructor(data: any) { + super(); + this.title = new Text(data.title); + this.selected = data.selected || false; + this.endpoint = new NavigationEndpoint(data.endpoint); + } +} + +export default ItemSectionTab; \ No newline at end of file diff --git a/src/parser/classes/ItemSectionTabbedHeader.ts b/src/parser/classes/ItemSectionTabbedHeader.ts new file mode 100644 index 00000000..139b6b4f --- /dev/null +++ b/src/parser/classes/ItemSectionTabbedHeader.ts @@ -0,0 +1,23 @@ +import Text from './misc/Text'; +import { ObservedArray, YTNode } from '../helpers'; +import ItemSectionTab from './ItemSectionTab'; +import Parser from '..'; + +class ItemSectionTabbedHeader extends YTNode { + static type = 'ItemSectionTabbedHeader'; + + title: Text; + tabs: Array; + end_items?: ObservedArray; + + constructor(data: any) { + super(); + this.title = new Text(data.title); + this.tabs = Parser.parseArray(data.tabs, ItemSectionTab); + if (data.endItems) { + this.end_items = Parser.parseArray(data.endItems); + } + } +} + +export default ItemSectionTabbedHeader; \ No newline at end of file diff --git a/src/parser/classes/MusicResponsiveListItem.ts b/src/parser/classes/MusicResponsiveListItem.ts index bee0fc56..7378f932 100644 --- a/src/parser/classes/MusicResponsiveListItem.ts +++ b/src/parser/classes/MusicResponsiveListItem.ts @@ -84,12 +84,17 @@ class MusicResponsiveListItem extends YTNode { this.#parsePlaylist(); break; case 'MUSIC_PAGE_TYPE_ARTIST': + case 'MUSIC_PAGE_TYPE_LIBRARY_ARTIST': case 'MUSIC_PAGE_TYPE_USER_CHANNEL': this.item_type = 'artist'; this.#parseArtist(); break; default: - this.#parseVideoOrSong(); + if (this.#flex_columns[1]) { + this.#parseVideoOrSong(); + } else { + this.#parseOther(); + } break; } @@ -97,12 +102,22 @@ class MusicResponsiveListItem extends YTNode { this.index = new Text(data.index); } - this.thumbnails = data.thumbnail ? Thumbnail.fromResponse(data.thumbnail.musicThumbnailRenderer.thumbnail) : []; + this.thumbnails = data.thumbnail ? Thumbnail.fromResponse(data.thumbnail.musicThumbnailRenderer?.thumbnail) : []; this.badges = Parser.parseArray(data.badges); this.menu = Parser.parse(data.menu); this.overlay = Parser.parse(data.overlay); } + #parseOther() { + this.title = this.#flex_columns[0].key('title').instanceof(Text).toString(); + + if (this.endpoint) { + this.item_type = 'endpoint'; + } else { + this.item_type = 'unknown'; + } + } + #parseVideoOrSong() { const is_video = this.#flex_columns[1].key('title').instanceof(Text).runs?.some((run) => run.text.match(/(.*?) views/)); if (is_video) { @@ -127,7 +142,8 @@ class MusicResponsiveListItem extends YTNode { seconds: timeToSeconds(duration_text) }); - const album = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('MPR')) as TextRun; + const album = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('MPR')) as TextRun || + this.#flex_columns[2]?.key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('MPR')) as TextRun; if (album) { this.album = { id: album.endpoint?.browse?.id, diff --git a/src/parser/classes/MusicTwoRowItem.js b/src/parser/classes/MusicTwoRowItem.js index 92124065..6d86d767 100644 --- a/src/parser/classes/MusicTwoRowItem.js +++ b/src/parser/classes/MusicTwoRowItem.js @@ -45,10 +45,16 @@ class MusicTwoRowItem extends YTNode { delete this.year; break; default: - if (this.subtitle.runs[0].text !== 'Song') { - this.item_type = 'video'; + if (this.subtitle.runs?.[0]) { + if (this.subtitle.runs[0].text !== 'Song') { + this.item_type = 'video'; + } else { + this.item_type = 'song'; + } + } else if (this.endpoint) { + this.item_type = 'endpoint'; } else { - this.item_type = 'song'; + this.item_type = 'unknown'; } if (this.item_type == 'video') { @@ -63,7 +69,7 @@ class MusicTwoRowItem extends YTNode { endpoint: author.endpoint }; } - } else { + } else if (this.item_type == 'song') { const artists = this.subtitle.runs.filter((run) => run.endpoint?.browse?.id.startsWith('UC')); if (artists) { this.artists = artists.map((artist) => ({ diff --git a/src/parser/classes/NavigationEndpoint.ts b/src/parser/classes/NavigationEndpoint.ts index 0e9451b1..efe35572 100644 --- a/src/parser/classes/NavigationEndpoint.ts +++ b/src/parser/classes/NavigationEndpoint.ts @@ -2,6 +2,7 @@ import { YTNode } from '../helpers'; import Parser, { ParsedResponse } from '../index'; import Actions, { ActionsResponse } from '../../core/Actions'; +import CreatePlaylistDialog from './CreatePlaylistDialog'; class NavigationEndpoint extends YTNode { static type = 'NavigationEndpoint'; @@ -30,6 +31,7 @@ class NavigationEndpoint extends YTNode { watch_playlist; playlist_edit; add_to_playlist; + create_playlist; get_report_form; live_chat_item_context_menu; send_live_chat_vote; @@ -191,6 +193,17 @@ class NavigationEndpoint extends YTNode { }; } + if (data?.createPlaylistEndpoint) { + if (data?.createPlaylistEndpoint.createPlaylistDialog) { + this.dialog = Parser.parseItem(data?.createPlaylistEndpoint.createPlaylistDialog, CreatePlaylistDialog); + } + this.create_playlist = { + // Nothing to put here - data.createPlaylistEndpoint has only one prop `createPlaylistDialog` + // Which was already parsed and referred to by `this.dialog`. But still useful to have this as + // A quick indicator of what the endpoint does. + }; + } + if (data?.getReportFormEndpoint) { this.get_report_form = { params: data.getReportFormEndpoint.params diff --git a/src/parser/classes/PlaylistPanel.ts b/src/parser/classes/PlaylistPanel.ts index 012f5532..4c068950 100644 --- a/src/parser/classes/PlaylistPanel.ts +++ b/src/parser/classes/PlaylistPanel.ts @@ -24,7 +24,7 @@ class PlaylistPanel extends YTNode { this.contents = Parser.parseArray(data.contents, PlaylistPanelVideo); this.playlist_id = data.playlistId; this.is_infinite = data.isInfinite; - this.continuation = data.continuations[0]?.nextRadioContinuationData?.continuation; + this.continuation = data.continuations[0]?.nextRadioContinuationData?.continuation || data.continuations[0]?.nextContinuationData?.continuation; this.is_editable = data.isEditable; this.preview_description = data.previewDescription; this.num_items_to_show = data.numItemsToShow; diff --git a/src/parser/index.ts b/src/parser/index.ts index 8f2ecc27..a96a7809 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -60,6 +60,49 @@ export class MusicPlaylistShelfContinuation extends YTNode { } } +export class MusicShelfContinuation extends YTNode { + static readonly type = 'musicShelfContinuation'; + + continuation: string; + contents: ObservedArray | null; + + constructor(data: any) { + super(); + this.contents = Parser.parse(data.contents, true); + this.continuation = data.continuations?.[0].nextContinuationData.continuation || null; + } +} + +export class GridContinuation extends YTNode { + static readonly type = 'gridContinuation'; + + continuation: string; + items: ObservedArray | null; + + constructor(data: any) { + super(); + this.items = Parser.parse(data.items, true); + this.continuation = data.continuations?.[0].nextContinuationData.continuation || null; + } + + get contents() { + return this.items; + } +} + +export class PlaylistPanelContinuation extends YTNode { + static readonly type = 'playlistPanelContinuation'; + + continuation: string; + contents: ObservedArray | null; + + constructor(data: any) { + super(); + this.contents = Parser.parse(data.contents, true); + this.continuation = data.continuations?.[0].nextContinuationData.continuation || null; + } +} + export class TimedContinuation extends YTNode { static readonly type = 'timedContinuationData'; @@ -235,6 +278,12 @@ export default class Parser { return new LiveChatContinuation(data.liveChatContinuation); if (data.musicPlaylistShelfContinuation) return new MusicPlaylistShelfContinuation(data.musicPlaylistShelfContinuation); + if (data.musicShelfContinuation) + return new MusicShelfContinuation(data.musicShelfContinuation); + if (data.gridContinuation) + return new GridContinuation(data.gridContinuation); + if (data.playlistPanelContinuation) + return new PlaylistPanelContinuation(data.playlistPanelContinuation); } static parseRR(actions: any[]) { diff --git a/src/parser/map.ts b/src/parser/map.ts index 500eb625..5b242983 100644 --- a/src/parser/map.ts +++ b/src/parser/map.ts @@ -11,6 +11,7 @@ import { default as AnalyticsVideo } from './classes/analytics/AnalyticsVideo'; import { default as AnalyticsVodCarouselCard } from './classes/analytics/AnalyticsVodCarouselCard'; import { default as CtaGoToCreatorStudio } from './classes/analytics/CtaGoToCreatorStudio'; import { default as DataModelSection } from './classes/analytics/DataModelSection'; +import { default as AutomixPreviewVideo } from './classes/AutomixPreviewVideo'; import { default as BackstageImage } from './classes/BackstageImage'; import { default as BackstagePost } from './classes/BackstagePost'; import { default as BackstagePostThread } from './classes/BackstagePostThread'; @@ -46,8 +47,11 @@ import { default as CompactMix } from './classes/CompactMix'; import { default as CompactPlaylist } from './classes/CompactPlaylist'; import { default as CompactVideo } from './classes/CompactVideo'; import { default as ContinuationItem } from './classes/ContinuationItem'; +import { default as CreatePlaylistDialog } from './classes/CreatePlaylistDialog'; import { default as DidYouMean } from './classes/DidYouMean'; import { default as DownloadButton } from './classes/DownloadButton'; +import { default as Dropdown } from './classes/Dropdown'; +import { default as DropdownItem } from './classes/DropdownItem'; import { default as Element } from './classes/Element'; import { default as EmergencyOnebox } from './classes/EmergencyOnebox'; import { default as Endscreen } from './classes/Endscreen'; @@ -67,6 +71,8 @@ import { default as HorizontalCardList } from './classes/HorizontalCardList'; import { default as HorizontalList } from './classes/HorizontalList'; import { default as ItemSection } from './classes/ItemSection'; import { default as ItemSectionHeader } from './classes/ItemSectionHeader'; +import { default as ItemSectionTab } from './classes/ItemSectionTab'; +import { default as ItemSectionTabbedHeader } from './classes/ItemSectionTabbedHeader'; import { default as LikeButton } from './classes/LikeButton'; import { default as LiveChat } from './classes/LiveChat'; import { default as AddBannerToLiveChatCommand } from './classes/livechat/AddBannerToLiveChatCommand'; @@ -243,6 +249,7 @@ const map: Record = { AnalyticsVodCarouselCard, CtaGoToCreatorStudio, DataModelSection, + AutomixPreviewVideo, BackstageImage, BackstagePost, BackstagePostThread, @@ -278,8 +285,11 @@ const map: Record = { CompactPlaylist, CompactVideo, ContinuationItem, + CreatePlaylistDialog, DidYouMean, DownloadButton, + Dropdown, + DropdownItem, Element, EmergencyOnebox, Endscreen, @@ -299,6 +309,8 @@ const map: Record = { HorizontalList, ItemSection, ItemSectionHeader, + ItemSectionTab, + ItemSectionTabbedHeader, LikeButton, LiveChat, AddBannerToLiveChatCommand, diff --git a/src/parser/ytmusic/Library.ts b/src/parser/ytmusic/Library.ts index f650b6aa..28c60104 100644 --- a/src/parser/ytmusic/Library.ts +++ b/src/parser/ytmusic/Library.ts @@ -1,17 +1,314 @@ -import Parser, { ParsedResponse } from '..'; -import { AxioslikeResponse } from '../../core/Actions'; +import Parser, { GridContinuation, MusicShelfContinuation, ParsedResponse, PlaylistPanelContinuation, SectionListContinuation } from '..'; +import Actions from '../../core/Actions'; +import { InnertubeError } from '../../utils/Utils'; +import DropdownItem from '../classes/DropdownItem'; +import NavigationEndpoint from '../classes/NavigationEndpoint'; +import PlaylistPanel from '../classes/PlaylistPanel'; +import SectionList from '../classes/SectionList'; + +type ContentType = 'history' | 'playlists' | 'albums' | 'songs' | 'artists' | 'subscriptions'; +type Continuation = { + type: 'browse' | 'next'; + token: string, + payload?: {} +}; +type ItemFilter = ((item: any) => boolean) | null; +type SortBy = 'recently_added' | 'a_z' | 'z_a'; + +const BROWSE_IDS: { [key: string]: string } = { + 'history': 'FEmusic_history', + 'playlists': 'FEmusic_liked_playlists', + 'albums': 'FEmusic_liked_albums', + 'songs': 'FEmusic_liked_videos', + 'artists': 'FEmusic_library_corpus_track_artists', + 'subscriptions': 'FEmusic_library_corpus_artists' +}; + +const SORT_BY_TEXTS: { [key: string]: string } = { + 'recently_added': 'Recently added', + 'a_z': 'A to Z', + 'z_a': 'Z to A' +}; + +const SORT_BY_TEXTS_R: { [key: string]: string } = {}; +for (const [ key, value ] of Object.entries(SORT_BY_TEXTS)) { + SORT_BY_TEXTS_R[value] = key; +} class Library { - #page; + #actions; - constructor(response: AxioslikeResponse) { - this.#page = Parser.parseResponse(response.data); - // TODO: finish this + constructor(actions: Actions) { + this.#actions = actions; } - get page(): ParsedResponse { + #getBrowseId(type: ContentType) { + return BROWSE_IDS[type]; + } + + async #fetchPage(browse_id: string, fetchArgs = {}) { + const response = await this.#actions.browse(browse_id, { ...fetchArgs, client: 'YTMUSIC' }); + return Parser.parseResponse(response.data); + } + + /** + * Fetches the list of library items from the endpoint given by `browse_id` + * @param browse_id - id of browse endpoint from which contents are fetched + * @param filter - The filter to apply to fetched items (`null` for no filtering) + * @param fetchArgs - Args to be included in the fetch payload + */ + async #fetchAndParseTabContents(browse_id: string, filter: ItemFilter = null, fetchArgs = {}) { + + const getItemsFromDataNode = (node: any) => { + switch (node?.type) { + case 'Grid': + return node.contents?.array(); + case 'MusicShelf': + return node.contents; + default: + return []; + } + }; + + const page = await this.#fetchPage(browse_id, fetchArgs); + const sections = page.contents_memo.get('SectionList')?.[0].as(SectionList).contents.array() as Array || []; + const contents_section = sections.find((section) => section.header?.type === 'ItemSectionTabbedHeader'); + const data_node = contents_section?.contents?.[0]; + const continuation = data_node?.continuation ? { + type: 'browse', + token: data_node?.continuation + } as Continuation : null; + return new LibraryItemList(getItemsFromDataNode(data_node) || [], filter, continuation, page, this.#actions); + } + + /** + * Retrieves the library's playlists + */ + async getPlaylists(args?: { sort_by?: SortBy }) { + const data = await this.#fetchAndParseTabContents(this.#getBrowseId('playlists'), (item) => item.item_type === 'playlist'); + const sort_by = args?.sort_by || null; + return sort_by ? this.#applySortBy(data, sort_by) : data; + } + + /** + * Retrieves the library's albums + */ + async getAlbums(args?: { sort_by?: SortBy }) { + const data = await this.#fetchAndParseTabContents(this.#getBrowseId('albums'), (item) => item.item_type === 'album'); + const sort_by = args?.sort_by || null; + return sort_by ? this.#applySortBy(data, sort_by) : data; + } + + /** + * Retrieves the library's artists + */ + async getArtists(args?: { sort_by?: SortBy }) { + const data = await this.#fetchAndParseTabContents(this.#getBrowseId('artists'), (item) => item.item_type === 'artist'); + const sort_by = args?.sort_by || null; + return sort_by ? this.#applySortBy(data, sort_by) : data; + } + + /** + * Retrieves the library's songs + */ + async getSongs(args?: { sort_by?: SortBy | 'random' }) { + const data = await this.#fetchAndParseTabContents(this.#getBrowseId('songs'), (item) => (item.item_type === 'song' || item.item_type === 'video')); + + const sort_by = args?.sort_by || null; + const shuffle = (sort_by === 'random'); + + const shuffle_endpoint = shuffle ? + data.all_items.find((item) => + item.item_type === 'endpoint' && item.title.toString() === 'Shuffle all' + )?.endpoint as NavigationEndpoint : null; + + if (shuffle) { + if (!shuffle_endpoint) { + throw new InnertubeError('Unable to obtain endpoint for sort_by value \'random\''); + } + return this.#fetchAndParseShuffledSongs(shuffle_endpoint); + } + + return sort_by ? this.#applySortBy(data, sort_by) : data; + } + + /** + * Fetches and returns a list of shuffled songs + * @param endpoint - The endpoint of the playlist containing the shuffled songs + */ + async #fetchAndParseShuffledSongs(endpoint: NavigationEndpoint) { + const payload = { + playlist_id: endpoint.payload.playlistId, + params: endpoint.payload.params + }; + const response = await this.#actions.next({ ...payload, client: 'YTMUSIC' }); + const page = Parser.parseResponse(response.data); + const playlist_panel = page.contents_memo.get('PlaylistPanel')?.[0].as(PlaylistPanel); + const items = playlist_panel?.contents || []; + const continuation = playlist_panel?.continuation ? { + type: 'next', + token: playlist_panel?.continuation, + payload + } as Continuation : null; + const filter = (item: any) => item.type === 'PlaylistPanelVideo'; + return new LibraryItemList(items, filter, continuation, page, this.#actions, { sort_by: 'random' }); + } + + /** + * Retrieves the library's subscriptions + */ + async getSubscriptions(args?: { sort_by?: SortBy }) { + const data = await this.#fetchAndParseTabContents(this.#getBrowseId('subscriptions')); + const sort_by = args?.sort_by || null; + return sort_by ? this.#applySortBy(data, sort_by) : data; + } + + /** + * Applies `sort_by` to `data` and returns the result. Original `data` is not modified. + */ + async #applySortBy(data: LibraryItemList, sort_by: SortBy) { + const page = data.page; + const dropdownItem = page?.contents_memo.get('DropdownItem')?.find( + (item) => item.as(DropdownItem).label === SORT_BY_TEXTS[sort_by])?.as(DropdownItem); + + if (!dropdownItem?.endpoint?.browse) { + throw new InnertubeError(`Unable to obtain browse endpoint for sort_by value '${sort_by}'`); + } + + if (dropdownItem?.selected) { + return data; + } + + const fetchArgs = { params: dropdownItem.endpoint.browse.params }; + return this.#fetchAndParseTabContents(dropdownItem.endpoint.browse.id, data.filter, fetchArgs); + } + + /** + * Retrieves recent activity + */ + async getRecentActivity(args: {all: boolean}) { + const all = !!args?.all; + if (all) { + const page = await this.#fetchPage(this.#getBrowseId('history')); + const section_list = page.contents_memo.get('SectionList')?.[0].as(SectionList); + const sections = section_list?.contents?.array() || []; + const continuation = section_list?.continuation ? { + type: 'browse', + token: section_list?.continuation + } as Continuation : null; + return new LibrarySectionList(sections, continuation, page, this.#actions); + } + + const page = await this.#fetchPage(this.#getBrowseId('songs')); + const sections = page.contents_memo.get('SectionList')?.[0].as(SectionList).contents.array() as Array || []; + const contents_section = sections.find( + (section) => section.header?.type === 'MusicCarouselShelfBasicHeader' && section.header?.title.toString() === 'Recent activity'); + const items = contents_section?.contents || []; + return new LibraryItemList(items, null, null, page, this.#actions, { sort_by: null }); + } +} + +abstract class LibraryResultsBase { + #continuation; + #page; + #actions; + has_continuation: boolean; + + constructor(continuation: Continuation | null, page: ParsedResponse, actions: Actions) { + this.#continuation = continuation; + this.#page = page; + this.#actions = actions; + this.has_continuation = !!continuation; + } + + async getContinuation() { + if (!this.#continuation) { + throw new InnertubeError('Continuation not found.'); + } + + let responsePromise; + const payload = this.#continuation.payload || {}; + switch (this.#continuation.type) { + case 'next': + responsePromise = this.#actions.next({ ...payload, ctoken: this.#continuation.token, client: 'YTMUSIC' }); + break; + default: + responsePromise = this.#actions.browse(this.#continuation.token, { ...payload, is_ctoken: true, client: 'YTMUSIC' }); + } + const response = await responsePromise; + const page = Parser.parseResponse(response.data); + + if (!page.continuation_contents) { + throw new InnertubeError('No continuation data found.'); + } + + return this.parseContinuationContents(page, this.#continuation); + } + + get page() { return this.#page; } + + abstract parseContinuationContents(page: ParsedResponse, from_continuation: Continuation): Promise; +} + +class LibraryItemList extends LibraryResultsBase { + #filter; + #actions; + #all_items; // Unfiltered items + items; // Items after applying filter (if any) + sort_by: SortBy | 'random' | null; + + constructor(items: Array, filter: ItemFilter, continuation: Continuation | null, page: ParsedResponse, actions: Actions, overrides?: { sort_by: SortBy | 'random' | null }) { + super(continuation, page, actions); + this.#filter = filter; + this.#actions = actions; + this.#all_items = items; + this.items = filter ? items.filter(filter) : items; + this.sort_by = (overrides?.sort_by !== undefined) ? overrides.sort_by : this.#getSortBy(); + } + + async parseContinuationContents(page: ParsedResponse, from_continuation: Continuation) { + const data = page.continuation_contents?.as(MusicShelfContinuation, GridContinuation, PlaylistPanelContinuation); + const continuation = data?.continuation ? { ...from_continuation, token: data?.continuation } : null; + return new LibraryItemList(data?.contents || [], this.#filter, continuation, page, this.#actions, { sort_by: this.sort_by }); + } + + #getSortBy() { + const selected = this.page?.contents_memo.get('DropdownItem')?.filter((item) => item.as(DropdownItem).selected) as DropdownItem[] || []; + for (const s of selected) { + const v = SORT_BY_TEXTS_R[s.label]; + if (v) { + return v as SortBy; + } + } + return null; + } + + get all_items() { + return this.#all_items; + } + + get filter() { + return this.#filter; + } +} + +class LibrarySectionList extends LibraryResultsBase { + #actions; + sections; + + constructor(sections: Array, continuation: Continuation | null, page: ParsedResponse, actions: Actions) { + super(continuation, page, actions); + this.#actions = actions; + this.sections = sections; + } + + async parseContinuationContents(page: ParsedResponse, from_continuation: Continuation) { + const data = page.continuation_contents?.as(SectionListContinuation); + const continuation = data?.continuation ? { ...from_continuation, token: data?.continuation } : null; + return new LibrarySectionList(data?.contents || [], continuation, page, this.#actions); + } } export default Library; \ No newline at end of file