feat(ytmusic): implement music#Library (#136)

* feat: add ItemSectionTab and related parsers

* feat: add `continuation` to `Grid`parser class

* feat (ytmusic): implement music#getLibrary()

* Improve album fetch in `MusicResponsiveListItem`

* music#Library: return [] for empty results

* feat: add `Dropdown` & `DropdownItem` parsers

* feat: add `CreatePlaylistDialog` parser

* feat: add `create_playlist` to NavigationEndpoint

* feat: add `AutomixPreviewVideo` parser

* feat: improve parsing of items

* fix: `PlaylistPanel` continuation

* feat: more args in `Actions#next`

* feat: add `PlaylistPanelContinuation` to `Parser`

* chore: update parser-map

* music#Library: refactor + add shuffle songs opt

* feat: add `endpoint` to `DropdownItem`

* feat: add `end_items` to `ItemSectionTabbedHeader`

* feat(ytmusic): add `sort_by` to `music#Library`
This commit is contained in:
Patrick Kan
2022-08-14 04:39:35 +08:00
committed by GitHub
parent e82302a6ea
commit f6a2a418be
17 changed files with 566 additions and 22 deletions

View File

@@ -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<string, any> = { 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),

View File

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

View File

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

View File

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

View File

@@ -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<DropdownItem>;
constructor(data: any) {
super();
this.label = data.label || '';
this.entries = Parser.parseArray(data.entries, DropdownItem);
}
}
export default Dropdown;

View File

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

View File

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

View File

@@ -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<ItemSectionHeader>(data.header, ItemSectionHeader);
this.header = Parser.parseItem<ItemSectionHeader | ItemSectionTabbedHeader>(data.header, [ ItemSectionHeader, ItemSectionTabbedHeader ]);
this.contents = Parser.parse(data.contents, true);
if (data.targetId || data.sectionIdentifier) {

View File

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

View File

@@ -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<ItemSectionTab>;
end_items?: ObservedArray<YTNode>;
constructor(data: any) {
super();
this.title = new Text(data.title);
this.tabs = Parser.parseArray<ItemSectionTab>(data.tabs, ItemSectionTab);
if (data.endItems) {
this.end_items = Parser.parseArray(data.endItems);
}
}
}
export default ItemSectionTabbedHeader;

View File

@@ -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,

View File

@@ -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) => ({

View File

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

View File

@@ -24,7 +24,7 @@ class PlaylistPanel extends YTNode {
this.contents = Parser.parseArray<PlaylistPanelVideo>(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;

View File

@@ -60,6 +60,49 @@ export class MusicPlaylistShelfContinuation extends YTNode {
}
}
export class MusicShelfContinuation extends YTNode {
static readonly type = 'musicShelfContinuation';
continuation: string;
contents: ObservedArray<YTNode> | 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<YTNode> | 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<YTNode> | 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[]) {

View File

@@ -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<string, YTNodeConstructor> = {
AnalyticsVodCarouselCard,
CtaGoToCreatorStudio,
DataModelSection,
AutomixPreviewVideo,
BackstageImage,
BackstagePost,
BackstagePostThread,
@@ -278,8 +285,11 @@ const map: Record<string, YTNodeConstructor> = {
CompactPlaylist,
CompactVideo,
ContinuationItem,
CreatePlaylistDialog,
DidYouMean,
DownloadButton,
Dropdown,
DropdownItem,
Element,
EmergencyOnebox,
Endscreen,
@@ -299,6 +309,8 @@ const map: Record<string, YTNodeConstructor> = {
HorizontalList,
ItemSection,
ItemSectionHeader,
ItemSectionTab,
ItemSectionTabbedHeader,
LikeButton,
LiveChat,
AddBannerToLiveChatCommand,

View File

@@ -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<any> || [];
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<any> || [];
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<LibraryResultsBase>;
}
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<any>, 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<any>, 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;