mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-06-26 16:18:51 +00:00
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:
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
19
src/parser/classes/AutomixPreviewVideo.ts
Normal file
19
src/parser/classes/AutomixPreviewVideo.ts
Normal 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;
|
||||
27
src/parser/classes/CreatePlaylistDialog.ts
Normal file
27
src/parser/classes/CreatePlaylistDialog.ts
Normal 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;
|
||||
19
src/parser/classes/Dropdown.ts
Normal file
19
src/parser/classes/Dropdown.ts
Normal 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;
|
||||
34
src/parser/classes/DropdownItem.ts
Normal file
34
src/parser/classes/DropdownItem.ts
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
21
src/parser/classes/ItemSectionTab.ts
Normal file
21
src/parser/classes/ItemSectionTab.ts
Normal 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;
|
||||
23
src/parser/classes/ItemSectionTabbedHeader.ts
Normal file
23
src/parser/classes/ItemSectionTabbedHeader.ts
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user