mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-06-19 04:21:35 +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:
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;
|
||||
|
||||
Reference in New Issue
Block a user