From 7fc9b526b00abdbe40e4915696677111180e4ea4 Mon Sep 17 00:00:00 2001 From: LuanRT Date: Mon, 20 Jun 2022 06:08:24 -0300 Subject: [PATCH] feat(ytmusic): add support for artists Available through `Innertube#music.getArtist(id: string)` #78 --- lib/core/Music.js | 11 ++++++ .../contents/classes/MusicCarouselShelf.js | 3 -- .../contents/classes/MusicDescriptionShelf.js | 9 ++++- .../contents/classes/MusicImmersiveHeader.js | 23 +++++++++++ .../contents/classes/MusicPlaylistShelf.js | 22 +++++++++++ .../classes/MusicResponsiveListItem.js | 2 +- lib/parser/contents/classes/MusicShelf.js | 13 +++++-- lib/parser/contents/classes/MusicThumbnail.js | 13 +++++++ lib/parser/contents/classes/ToggleButton.js | 2 +- lib/parser/ytmusic/Artist.js | 39 +++++++++++++++++++ lib/parser/ytmusic/Search.js | 17 ++++---- lib/utils/Utils.js | 8 +++- test/main.test.js | 2 +- 13 files changed, 142 insertions(+), 22 deletions(-) create mode 100644 lib/parser/contents/classes/MusicImmersiveHeader.js create mode 100644 lib/parser/contents/classes/MusicPlaylistShelf.js create mode 100644 lib/parser/contents/classes/MusicThumbnail.js create mode 100644 lib/parser/ytmusic/Artist.js diff --git a/lib/core/Music.js b/lib/core/Music.js index 7740107d..c6254bcc 100644 --- a/lib/core/Music.js +++ b/lib/core/Music.js @@ -5,6 +5,7 @@ const Search = require('../parser/ytmusic/Search'); const HomeFeed = require('../parser/ytmusic/HomeFeed'); const Explore = require('../parser/ytmusic/Explore'); const Library = require('../parser/ytmusic/Library'); +const Artist = require('../parser/ytmusic/Artist'); const { InnertubeError, observe } = require('../utils/Utils'); @@ -64,6 +65,16 @@ class Music { return new Library(response, this.#actions); } + /** + * Retrieves artist's info & content. + * + * @param {string} id + */ + async getArtist(artist_id) { + const response = await this.#actions.browse(artist_id, { client: 'YTMUSIC' }); + return new Artist(response, this.#actions); + } + /** * Retrieves song lyrics. * diff --git a/lib/parser/contents/classes/MusicCarouselShelf.js b/lib/parser/contents/classes/MusicCarouselShelf.js index 5ff6cc58..e87aed05 100644 --- a/lib/parser/contents/classes/MusicCarouselShelf.js +++ b/lib/parser/contents/classes/MusicCarouselShelf.js @@ -9,9 +9,6 @@ class MusicCarouselShelf { this.header = Parser.parse(data.header); this.contents = Parser.parse(data.contents); - data.itemSize && - (this.item_size = data.itemSize); - data.numItemsPerColumn && (this.num_items_per_column = data.numItemsPerColumn); } diff --git a/lib/parser/contents/classes/MusicDescriptionShelf.js b/lib/parser/contents/classes/MusicDescriptionShelf.js index 79712fac..5c17f7de 100644 --- a/lib/parser/contents/classes/MusicDescriptionShelf.js +++ b/lib/parser/contents/classes/MusicDescriptionShelf.js @@ -7,8 +7,13 @@ class MusicDescriptionShelf { constructor(data) { this.description = new Text(data.description); - this.max_collapsed_lines = data.maxCollapsedLines || null; - this.max_expanded_lines = data.maxExpandedLines || null; + + this.max_collapsed_lines && + (this.max_collapsed_lines = data.maxCollapsedLines); + + this.max_expanded_lines && + (this.max_expanded_lines = data.maxExpandedLines); + this.footer = new Text(data.footer); } } diff --git a/lib/parser/contents/classes/MusicImmersiveHeader.js b/lib/parser/contents/classes/MusicImmersiveHeader.js new file mode 100644 index 00000000..23109730 --- /dev/null +++ b/lib/parser/contents/classes/MusicImmersiveHeader.js @@ -0,0 +1,23 @@ +'use strict'; + +const Text = require('./Text'); +const Parser = require('..'); + +class MusicImmersiveHeader { + type = 'MusicImmersiveHeader'; + + constructor(data) { + this.title = new Text(data.title); + this.description = new Text(data.description); + this.thumbnails = Parser.parse(data.thumbnail); + + /** + Not useful for now. + this.menu = Parser.parse(data.menu); + this.play_button = Parser.parse(data.playButton); + this.start_radio_button = Parser.parse(data.startRadioButton); + */ + } +} + +module.exports = MusicImmersiveHeader; \ No newline at end of file diff --git a/lib/parser/contents/classes/MusicPlaylistShelf.js b/lib/parser/contents/classes/MusicPlaylistShelf.js new file mode 100644 index 00000000..0af9f516 --- /dev/null +++ b/lib/parser/contents/classes/MusicPlaylistShelf.js @@ -0,0 +1,22 @@ +'use strict'; + +const Parser = require('..'); + +class MusicPlaylistShelf { + type = 'MusicPlaylistShelf'; + + #continuations; + + constructor(data) { + this.playlist_id = data.playlistId; + this.contents = Parser.parse(data.contents); + this.collapsed_item_count = data.collapsedItemCount; + this.#continuations = data.continuations; + } + + get continuation() { + return this.#continuations?.[0]?.nextContinuationData; + } +} + +module.exports = MusicPlaylistShelf; \ No newline at end of file diff --git a/lib/parser/contents/classes/MusicResponsiveListItem.js b/lib/parser/contents/classes/MusicResponsiveListItem.js index fdd06c5d..d26ac1cd 100644 --- a/lib/parser/contents/classes/MusicResponsiveListItem.js +++ b/lib/parser/contents/classes/MusicResponsiveListItem.js @@ -111,7 +111,7 @@ class MusicResponsiveListItem { #parseArtist() { this.id = this.endpoint.browse.id; this.name = this.#flex_columns[0].title.toString(); - this.subscribers = this.#flex_columns[1].title.runs[2].text; + this.subscribers = this.#flex_columns[1].title.runs[2]?.text || ''; } #parseAlbum() { diff --git a/lib/parser/contents/classes/MusicShelf.js b/lib/parser/contents/classes/MusicShelf.js index fd7f1a3b..d547803b 100644 --- a/lib/parser/contents/classes/MusicShelf.js +++ b/lib/parser/contents/classes/MusicShelf.js @@ -8,13 +8,18 @@ class MusicShelf { type = 'MusicShelf'; constructor(data) { - this.title = new Text(data.title); + this.title = new Text(data.title).toString(); this.contents = Parser.parse(data.contents); - this.endpoint = data.bottomEndpoint && new NavigationEndpoint(data.bottomEndpoint) || null; - this.continuation = data.continuations?.[0].nextContinuationData.continuation || null; - this.bottom_text = data.bottomText && new Text(data.bottomText) || null; + data.bottomEndpoint && + (this.endpoint = new NavigationEndpoint(data.bottomEndpoint)); + + this.continuation && + (this.continuation = data.continuations?.[0].nextContinuationData.continuation); + + data.bottomText && + (this.bottom_text = new Text(data.bottomText)); } } diff --git a/lib/parser/contents/classes/MusicThumbnail.js b/lib/parser/contents/classes/MusicThumbnail.js new file mode 100644 index 00000000..fd93bd06 --- /dev/null +++ b/lib/parser/contents/classes/MusicThumbnail.js @@ -0,0 +1,13 @@ +'use strict'; + +const Thumbnail = require('./Thumbnail'); + +class MusicThumbnail { + type = 'MusicThumbnail'; + + constructor(data) { + return Thumbnail.fromResponse(data.thumbnail); + } +} + +module.exports = MusicThumbnail; \ No newline at end of file diff --git a/lib/parser/contents/classes/ToggleButton.js b/lib/parser/contents/classes/ToggleButton.js index 70b39396..496f9a8a 100644 --- a/lib/parser/contents/classes/ToggleButton.js +++ b/lib/parser/contents/classes/ToggleButton.js @@ -16,7 +16,7 @@ class ToggleButton { this.icon_type = data.defaultIcon.iconType; const acc_label = - data.defaultText?.accessibility.accessibilityData.label || + data.defaultText?.accessibility?.accessibilityData.label || data?.accessibility?.label; this.icon_type == 'LIKE' && diff --git a/lib/parser/ytmusic/Artist.js b/lib/parser/ytmusic/Artist.js new file mode 100644 index 00000000..640054b3 --- /dev/null +++ b/lib/parser/ytmusic/Artist.js @@ -0,0 +1,39 @@ +'use strict'; + +const Parser = require('../contents'); +const { observe } = require('../../utils/Utils'); + +/** @namespace */ +class Artist { + #page; + #actions; + + /** + * @param {object} response - API response. + * @param {import('../../core/Actions')} actions + */ + constructor(response, actions) { + this.#page = Parser.parseResponse(response.data); + this.#actions = actions; + + this.header = this.page.header; + + const music_shelf = this.#page.contents_memo.get('MusicShelf'); + const music_carousel_shelf = this.#page.contents_memo.get('MusicCarouselShelf'); + + /** @type {import('../contents/classes/MusicShelf')[] | import('../contents/classes/MusicCarouselShelf')[]} */ + this.sections = observe([ ...music_shelf, ...music_carousel_shelf ]); + } + + async getAllSongs() { + const shelf = this.sections.get({ type: 'MusicShelf' }); + const page = await shelf.endpoint.call(this.#actions, 'YTMUSIC'); + return page.contents_memo.get('MusicPlaylistShelf')?.[0] || []; + } + + get page() { + return this.#page; + } +} + +module.exports = Artist; \ No newline at end of file diff --git a/lib/parser/ytmusic/Search.js b/lib/parser/ytmusic/Search.js index 1ad4c5a1..d5fe4da1 100644 --- a/lib/parser/ytmusic/Search.js +++ b/lib/parser/ytmusic/Search.js @@ -54,7 +54,7 @@ class Search { /** @type {{ title: string; items: object[]; getMore: Promise.; }} */ this.sections = observe(shelves.map((shelf) => ({ - title: shelf.title.toString(), + title: shelf.title, contents: shelf.contents, getMore: () => this.#getMore(shelf) }))); @@ -62,7 +62,7 @@ class Search { async #getMore(shelf) { if (!shelf.endpoint) - throw new InnertubeError(shelf.title.toString() + ' doesn\'t have more items'); + throw new InnertubeError(shelf.title + ' doesn\'t have more items'); const response = await shelf.endpoint.call(this.#actions, 'YTMUSIC'); return new Search(response, this.#actions, { is_continuation: true }); @@ -86,12 +86,12 @@ class Search { return this; } - /** - * Applies given filter to the search. - * - * @param {string} name - * @returns {Promise.} - */ + /** + * Applies given filter to the search. + * + * @param {string} name + * @returns {Promise.} + */ async selectFilter(name) { if (!this.filters.includes(name)) throw new InnertubeError('Invalid filter', { available_filters: this.filters }); @@ -139,7 +139,6 @@ class Search { return this.sections.get({ title: 'Community playlists' }); } - /** @type {import('../contents/classes/MusicResponsiveListItem')[]} */ get page() { return this.#page; } diff --git a/lib/utils/Utils.js b/lib/utils/Utils.js index 74fa4490..b48d64d2 100644 --- a/lib/utils/Utils.js +++ b/lib/utils/Utils.js @@ -119,7 +119,13 @@ function deepCompare(obj1, obj2) { const keys = Reflect.ownKeys(obj1); return keys.some((key) => { - return obj1[key] === (obj2[key].constructor.name === 'Text' ? obj2[key].toString() : obj2[key]); + const is_text = obj2[key]?.constructor.name === 'Text'; + + if (!is_text && typeof obj2[key] === 'object') { + return JSON.stringify(obj1[key]) === JSON.stringify(obj2[key]); + } + + return obj1[key] === (is_text ? obj2[key].toString() : obj2[key]); }); } diff --git a/test/main.test.js b/test/main.test.js index 1810c86f..30303db4 100644 --- a/test/main.test.js +++ b/test/main.test.js @@ -68,7 +68,7 @@ describe('YouTube.js Tests', () => { describe('General', () => { it('Should retrieve home feed', async () => { const homefeed = await this.session.getHomeFeed(); - expect(homefeed.videos.length).toBeLessThanOrEqual(30); + expect(homefeed.videos.length).toBeLessThanOrEqual(40); }); it('Should retrieve trending content', async () => {