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

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