From f2f48af1bca59354615c4830016bf3dd57145dfc Mon Sep 17 00:00:00 2001 From: LuanRT Date: Tue, 13 Sep 2022 02:26:13 -0300 Subject: [PATCH] feat(Music): add automix support and other minor improvements (#184) * dev(NavigationEndpoint): add `/player` endpoint * dev: add AudioOnlyPlayability, BrowserMediaSession and MusicDownloadStateBadge * dev: allow endpoints to be overridden * dev: minor parser changes * dev(TrackInfo): add `#getTab(title?)` * dev: allow `Music#getInfo()` to accept list items * dev: revert a few changes, I probably overcomplicated this. * dev: add tests * dev: add `TrackInfo#getUpNext()`, `TrackInfo#getRelated()` and `TrackInfo#getLyrics()` * docs: update API ref * fix(docs): formatting inconsistencies --- docs/API/music.md | 35 ++- src/core/Actions.ts | 17 +- src/core/Music.ts | 202 ++++++++++++------ src/parser/classes/AudioOnlyPlayability.ts | 14 ++ src/parser/classes/BrowserMediaSession.ts | 18 ++ src/parser/classes/MusicDownloadStateBadge.ts | 16 ++ .../classes/MusicItemThumbnailOverlay.ts | 3 +- src/parser/classes/MusicQueue.ts | 5 +- src/parser/classes/MusicResponsiveListItem.ts | 16 +- src/parser/classes/MusicTwoRowItem.ts | 7 +- src/parser/classes/NavigationEndpoint.ts | 4 + src/parser/classes/PlayerOverlay.ts | 4 + src/parser/index.ts | 6 +- src/parser/map.ts | 6 + src/parser/ytmusic/TrackInfo.ts | 85 +++++++- test/main.test.ts | 63 ++++-- 16 files changed, 395 insertions(+), 106 deletions(-) create mode 100644 src/parser/classes/AudioOnlyPlayability.ts create mode 100644 src/parser/classes/BrowserMediaSession.ts create mode 100644 src/parser/classes/MusicDownloadStateBadge.ts diff --git a/docs/API/music.md b/docs/API/music.md index c86561dd..7c578ed1 100644 --- a/docs/API/music.md +++ b/docs/API/music.md @@ -5,7 +5,7 @@ YouTube Music class. ## API * Music - * [.getInfo(video_id)](#getinfo) + * [.getInfo(target)](#getinfo) * [.search(query, filters?)](#search) * [.getHomeFeed()](#gethomefeed) * [.getExplore()](#getexplore) @@ -14,13 +14,13 @@ YouTube Music class. * [.getAlbum(album_id)](#getalbum) * [.getPlaylist(playlist_id)](#getplaylist) * [.getLyrics(video_id)](#getlyrics) - * [.getUpNext(video_id)](#getupnext) + * [.getUpNext(video_id, automix?)](#getupnext) * [.getRelated(video_id)](#getrelated) * [.getRecap()](#getrecap) * [.getSearchSuggestions(query)](#getsearchsuggestions) -### getInfo(video_id) +### getInfo(target) Retrieves track info. @@ -28,7 +28,29 @@ Retrieves track info. | Param | Type | Description | | --- | --- | --- | -| video_id | `string` | Video id | +| target | `string` or `MusicTwoRowItem` | video id or list item | + +
+Methods & Getters +

+ +- `#getTab(title)` + - Retrieves contents of the given tab. + +- `#getUpNext(automix?)` + - Retrieves up next. + +- `#getRelated()` + - Retrieves related content. + +- `#getLyrics()` + - Retrieves song lyrics. + +- `#available_tabs` + - Returns available tabs. + +

+
### search(query, filters?) @@ -211,14 +233,14 @@ Retrieves given playlist. Retrieves song lyrics. -**Returns:** `Promise.<{ text: string; footer: object; }>` +**Returns:** `Promise.` | Param | Type | Description | | --- | --- | --- | | video_id | `string` | Video id | -### getUpNext(video_id) +### getUpNext(video_id, automix?) Retrieves up next content. @@ -227,6 +249,7 @@ Retrieves up next content. | Param | Type | Description | | --- | --- | --- | | video_id | `string` | Video id | +| automix? | `boolean` | if automix should be fetched | ### getRelated(video_id) diff --git a/src/core/Actions.ts b/src/core/Actions.ts index 74579611..0ecf2293 100644 --- a/src/core/Actions.ts +++ b/src/core/Actions.ts @@ -618,7 +618,7 @@ class Actions { /** * Used to retrieve video info. */ - async getVideoInfo(id: string, cpn?: string, client?: string) { + async getVideoInfo(id: string, cpn?: string, client?: string, playlist_id?: string) { const data: Record = { playbackContext: { contentPlaybackContext: { @@ -647,6 +647,10 @@ class Actions { data.cpn = cpn; } + if (playlist_id) { + data.playlistId = playlist_id; + } + const response = await this.#session.http.fetch('/player', { method: 'POST', body: JSON.stringify(data), @@ -719,6 +723,9 @@ class Actions { throw new InnertubeError('You are not signed in'); } + if (Reflect.has(data, 'override_endpoint')) + delete data.override_endpoint; + if (Reflect.has(data, 'parse')) delete data.parse; @@ -745,11 +752,17 @@ class Actions { data.continuation = data.token; delete data.token; } + + if (data?.client === 'YTMUSIC') { + data.isAudioOnly = true; + } } else { data = args.serialized_data; } - const response = await this.#session.http.fetch(action, { + const endpoint = Reflect.has(args, 'override_endpoint') ? args.override_endpoint : action; + + const response = await this.#session.http.fetch(endpoint, { method: 'POST', body: args.protobuf ? data : JSON.stringify(data), headers: { diff --git a/src/core/Music.ts b/src/core/Music.ts index 12d20152..d4846344 100644 --- a/src/core/Music.ts +++ b/src/core/Music.ts @@ -1,7 +1,6 @@ import Session from './Session'; import TrackInfo from '../parser/ytmusic/TrackInfo'; - import Search from '../parser/ytmusic/Search'; import HomeFeed from '../parser/ytmusic/HomeFeed'; import Explore from '../parser/ytmusic/Explore'; @@ -11,39 +10,94 @@ import Album from '../parser/ytmusic/Album'; import Playlist from '../parser/ytmusic/Playlist'; import Recap from '../parser/ytmusic/Recap'; -import Parser from '../parser/index'; -import { observe, YTNode } from '../parser/helpers'; - import Tab from '../parser/classes/Tab'; import Tabbed from '../parser/classes/Tabbed'; import SingleColumnMusicWatchNextResults from '../parser/classes/SingleColumnMusicWatchNextResults'; import WatchNextTabbedResults from '../parser/classes/WatchNextTabbedResults'; import SectionList from '../parser/classes/SectionList'; +import Message from '../parser/classes/Message'; import MusicQueue from '../parser/classes/MusicQueue'; import PlaylistPanel from '../parser/classes/PlaylistPanel'; -import Message from '../parser/classes/Message'; import MusicDescriptionShelf from '../parser/classes/MusicDescriptionShelf'; import MusicCarouselShelf from '../parser/classes/MusicCarouselShelf'; import SearchSuggestionsSection from '../parser/classes/SearchSuggestionsSection'; +import AutomixPreviewVideo from '../parser/classes/AutomixPreviewVideo'; +import MusicTwoRowItem from '../parser/classes/MusicTwoRowItem'; +import { observe, ObservedArray, YTNode } from '../parser/helpers'; import { InnertubeError, throwIfMissing, generateRandomString } from '../utils/Utils'; class Music { + #session; #actions; constructor(session: Session) { + this.#session = session; this.#actions = session.actions; } /** - * Retrieves track info. + * Retrives track info. Passing a list item of type MusicTwoRowItem automatically starts a radio. + * @param target - video id or a list item. */ - async getInfo(video_id: string) { + getInfo(target: string | MusicTwoRowItem): Promise { + if (target instanceof MusicTwoRowItem) { + return this.#fetchInfoFromListItem(target); + } else if (typeof target === 'string') { + return this.#fetchInfoFromVideoId(target); + } + + throw new InnertubeError('Invalid target, expected either a video id or a valid MusicTwoRowItem', target); + } + + async #fetchInfoFromVideoId(video_id: string) { const cpn = generateRandomString(16); - const initial_info = await this.#actions.getVideoInfo(video_id, cpn, 'YTMUSIC'); - const continuation = this.#actions.execute('/next', { client: 'YTMUSIC', videoId: video_id }); + const initial_info = this.#actions.execute('/player', { + cpn, + client: 'YTMUSIC', + videoId: video_id, + playbackContext: { + contentPlaybackContext: { + signatureTimestamp: this.#session.player.sts + } + } + }); + + const continuation = this.#actions.execute('/next', { + client: 'YTMUSIC', + videoId: video_id + }); + + const response = await Promise.all([ initial_info, continuation ]); + return new TrackInfo(response, this.#actions, cpn); + } + + async #fetchInfoFromListItem(list_item: MusicTwoRowItem | undefined) { + if (!list_item) + throw new InnertubeError('List item cannot be undefined'); + + if (!list_item.endpoint) + throw new Error('This item does not have an endpoint.'); + + const cpn = generateRandomString(16); + + const initial_info = list_item.endpoint.callTest(this.#actions, { + cpn, + client: 'YTMUSIC', + playbackContext: { + contentPlaybackContext: { + signatureTimestamp: this.#session.player.sts + } + } + }); + + const continuation = list_item.endpoint.callTest(this.#actions, { + client: 'YTMUSIC', + enablePersistentPlaylistPanel: true, + override_endpoint: '/next' + }); const response = await Promise.all([ initial_info, continuation ]); return new TrackInfo(response, this.#actions, cpn); @@ -54,7 +108,7 @@ class Music { */ async search(query: string, filters: { type?: 'all' | 'song' | 'video' | 'album' | 'playlist' | 'artist'; - } = {}) { + } = {}): Promise { throwIfMissing({ query }); const response = await this.#actions.search({ query, filters, client: 'YTMUSIC' }); return new Search(response, this.#actions, { is_filtered: Reflect.has(filters, 'type') && filters.type !== 'all' }); @@ -63,7 +117,7 @@ class Music { /** * Retrieves the home feed. */ - async getHomeFeed() { + async getHomeFeed(): Promise { const response = await this.#actions.browse('FEmusic_home', { client: 'YTMUSIC' }); return new HomeFeed(response, this.#actions); } @@ -71,7 +125,7 @@ class Music { /** * Retrieves the Explore feed. */ - async getExplore() { + async getExplore(): Promise { const response = await this.#actions.browse('FEmusic_explore', { client: 'YTMUSIC' }); return new Explore(response); // TODO: return new Explore(response, this.#actions); @@ -87,7 +141,7 @@ class Music { /** * Retrieves artist's info & content. */ - async getArtist(artist_id: string) { + async getArtist(artist_id: string): Promise { throwIfMissing({ artist_id }); if (!artist_id.startsWith('UC') && !artist_id.startsWith('FEmusic_library_privately_owned_artist')) @@ -100,7 +154,7 @@ class Music { /** * Retrieves album. */ - async getAlbum(album_id: string) { + async getAlbum(album_id: string): Promise { throwIfMissing({ album_id }); if (!album_id.startsWith('MPR') && !album_id.startsWith('FEmusic_library_privately_owned_release')) @@ -113,7 +167,7 @@ class Music { /** * Retrieves playlist. */ - async getPlaylist(playlist_id: string) { + async getPlaylist(playlist_id: string): Promise { throwIfMissing({ playlist_id }); if (!playlist_id.startsWith('VL')) { @@ -124,53 +178,17 @@ class Music { return new Playlist(response, this.#actions); } - /** - * Retrieves song lyrics. - */ - async getLyrics(video_id: string) { - throwIfMissing({ video_id }); - - const response = await this.#actions.next({ video_id, client: 'YTMUSIC' }); - - const data = Parser.parseResponse(response.data); - - const tabs = data.contents.item() - .as(SingleColumnMusicWatchNextResults).contents.item() - .as(Tabbed).contents.item() - .as(WatchNextTabbedResults) - .tabs.array().as(Tab); - - const tab = tabs.get({ title: 'Lyrics' }); - - if (!tab) - throw new InnertubeError('Could not find target tab.'); - - const page = await tab.endpoint.call(this.#actions, 'YTMUSIC', true); - - if (!page) - throw new InnertubeError('Could not retrieve tab contents, the given id may be invalid or is not a song.'); - - if (page.contents.item().key('type').string() === 'Message') - throw new InnertubeError(page.contents.item().as(Message).text, video_id); - - const section_list = page.contents.item().as(SectionList).contents.array(); - const description_shelf = section_list.firstOfType(MusicDescriptionShelf); - - return { - text: description_shelf?.description.toString(), - footer: description_shelf?.footer - }; - } - /** * Retrieves up next. */ - async getUpNext(video_id: string) { + async getUpNext(video_id: string, automix = true): Promise { throwIfMissing({ video_id }); - const response = await this.#actions.next({ video_id, client: 'YTMUSIC' }); - - const data = Parser.parseResponse(response.data); + const data = await this.#actions.execute('/next', { + videoId: video_id, + client: 'YTMUSIC', + parse: true + }); const tabs = data.contents.item() .as(SingleColumnMusicWatchNextResults).contents.item() @@ -188,7 +206,25 @@ class Music { if (!music_queue || !music_queue.content) throw new InnertubeError('Music queue was empty, the given id is probably invalid.', music_queue); - const playlist_panel = music_queue.content.item().as(PlaylistPanel); + const playlist_panel = music_queue.content.as(PlaylistPanel); + + if (!playlist_panel.playlist_id && automix) { + const automix_preview_video = playlist_panel.contents.firstOfType(AutomixPreviewVideo); + + if (!automix_preview_video) + throw new InnertubeError('Automix item not found'); + + const page = await automix_preview_video.playlist_video?.endpoint.callTest(this.#actions, { + videoId: video_id, + client: 'YTMUSIC', + parse: true + }); + + if (!page) + throw new InnertubeError('Could not fetch automix'); + + return page.contents_memo.getType(PlaylistPanel)?.[0]; + } return playlist_panel; } @@ -196,12 +232,14 @@ class Music { /** * Retrieves related content. */ - async getRelated(video_id: string) { + async getRelated(video_id: string): Promise> { throwIfMissing({ video_id }); - const response = await this.#actions.next({ video_id, client: 'YTMUSIC' }); - - const data = Parser.parseResponse(response.data); + const data = await this.#actions.execute('/next', { + videoId: video_id, + client: 'YTMUSIC', + parse: true + }); const tabs = data.contents.item() .as(SingleColumnMusicWatchNextResults).contents.item() @@ -224,7 +262,45 @@ class Music { return shelves; } - async getRecap() { + /** + * Retrieves song lyrics. + */ + async getLyrics(video_id: string): Promise { + throwIfMissing({ video_id }); + + const data = await this.#actions.execute('/next', { + videoId: video_id, + client: 'YTMUSIC', + parse: true + }); + + const tabs = data.contents.item() + .as(SingleColumnMusicWatchNextResults).contents.item() + .as(Tabbed).contents.item() + .as(WatchNextTabbedResults) + .tabs.array().as(Tab); + + const tab = tabs.get({ title: 'Lyrics' }); + + if (!tab) + throw new InnertubeError('Could not find target tab.'); + + const page = await tab.endpoint.call(this.#actions, 'YTMUSIC', true); + + if (!page) + throw new InnertubeError('Could not retrieve tab contents, the given id may be invalid or is not a song.'); + + if (page.contents.item().key('type').string() === 'Message') + throw new InnertubeError(page.contents.item().as(Message).text, video_id); + + const section_list = page.contents.item().as(SectionList).contents.array(); + return section_list.firstOfType(MusicDescriptionShelf); + } + + /** + * Retrieves recap. + */ + async getRecap(): Promise { const response = await this.#actions.execute('/browse', { browseId: 'FEmusic_listening_review', client: 'YTMUSIC_ANDROID' diff --git a/src/parser/classes/AudioOnlyPlayability.ts b/src/parser/classes/AudioOnlyPlayability.ts new file mode 100644 index 00000000..2b52c134 --- /dev/null +++ b/src/parser/classes/AudioOnlyPlayability.ts @@ -0,0 +1,14 @@ +import { YTNode } from '../helpers'; + +class AudioOnlyPlayability extends YTNode { + static type = 'AudioOnlyPlayability'; + + audio_only_availability: string; + + constructor (data: any) { + super(); + this.audio_only_availability = data.audioOnlyAvailability; + } +} + +export default AudioOnlyPlayability; \ No newline at end of file diff --git a/src/parser/classes/BrowserMediaSession.ts b/src/parser/classes/BrowserMediaSession.ts new file mode 100644 index 00000000..b88387a1 --- /dev/null +++ b/src/parser/classes/BrowserMediaSession.ts @@ -0,0 +1,18 @@ +import Text from './misc/Text'; +import Thumbnail from './misc/Thumbnail'; +import { YTNode } from '../helpers'; + +class BrowserMediaSession extends YTNode { + static type = 'BrowserMediaSession'; + + album; + thumbnails; + + constructor (data: any) { + super(); + this.album = new Text(data.album); + this.thumbnails = Thumbnail.fromResponse(data.thumbnailDetails); + } +} + +export default BrowserMediaSession; \ No newline at end of file diff --git a/src/parser/classes/MusicDownloadStateBadge.ts b/src/parser/classes/MusicDownloadStateBadge.ts new file mode 100644 index 00000000..b568ebcd --- /dev/null +++ b/src/parser/classes/MusicDownloadStateBadge.ts @@ -0,0 +1,16 @@ +import { YTNode } from '../helpers'; + +class MusicDownloadStateBadge extends YTNode { + static type = 'MusicDownloadStateBadge'; + + playlist_id: string; + supported_download_states: string[]; + + constructor(data: any) { + super(); + this.playlist_id = data.playlistId; + this.supported_download_states = data.supportedDownloadStates; + } +} + +export default MusicDownloadStateBadge; \ No newline at end of file diff --git a/src/parser/classes/MusicItemThumbnailOverlay.ts b/src/parser/classes/MusicItemThumbnailOverlay.ts index 976779d2..1d87e374 100644 --- a/src/parser/classes/MusicItemThumbnailOverlay.ts +++ b/src/parser/classes/MusicItemThumbnailOverlay.ts @@ -1,4 +1,5 @@ import Parser from '../index'; +import MusicPlayButton from './MusicPlayButton'; import { YTNode } from '../helpers'; class MusicItemThumbnailOverlay extends YTNode { @@ -10,7 +11,7 @@ class MusicItemThumbnailOverlay extends YTNode { constructor(data: any) { super(); - this.content = Parser.parse(data.content); + this.content = Parser.parseItem(data.content, MusicPlayButton); this.content_position = data.contentPosition; this.display_style = data.displayStyle; } diff --git a/src/parser/classes/MusicQueue.ts b/src/parser/classes/MusicQueue.ts index 1813fb7a..cd48395b 100644 --- a/src/parser/classes/MusicQueue.ts +++ b/src/parser/classes/MusicQueue.ts @@ -1,14 +1,15 @@ import Parser from '../index'; +import PlaylistPanel from './PlaylistPanel'; import { YTNode } from '../helpers'; class MusicQueue extends YTNode { static type = 'MusicQueue'; - content; + content: PlaylistPanel | null; constructor(data: any) { super(); - this.content = Parser.parse(data.content); + this.content = Parser.parseItem(data.content, PlaylistPanel); } } diff --git a/src/parser/classes/MusicResponsiveListItem.ts b/src/parser/classes/MusicResponsiveListItem.ts index b28c3e88..945cd703 100644 --- a/src/parser/classes/MusicResponsiveListItem.ts +++ b/src/parser/classes/MusicResponsiveListItem.ts @@ -3,12 +3,16 @@ import Parser from '../index'; import Text from './misc/Text'; -import { timeToSeconds } from '../../utils/Utils'; +import TextRun from './misc/TextRun'; import Thumbnail from './misc/Thumbnail'; import NavigationEndpoint from './NavigationEndpoint'; +import MusicItemThumbnailOverlay from './MusicItemThumbnailOverlay'; +import MusicResponsiveListItemFlexColumn from './MusicResponsiveListItemFlexColumn'; +import MusicResponsiveListItemFixedColumn from './MusicResponsiveListItemFixedColumn'; +import Menu from './menus/Menu'; +import { timeToSeconds } from '../../utils/Utils'; import { YTNode } from '../helpers'; -import TextRun from './misc/TextRun'; class MusicResponsiveListItem extends YTNode { static type = 'MusicResponsiveListItem'; @@ -66,8 +70,8 @@ class MusicResponsiveListItem extends YTNode { constructor(data: any) { super(); - this.#flex_columns = Parser.parseArray(data.flexColumns); - this.#fixed_columns = Parser.parseArray(data.fixedColumns); + this.#flex_columns = Parser.parseArray(data.flexColumns, MusicResponsiveListItemFlexColumn); + this.#fixed_columns = Parser.parseArray(data.fixedColumns, MusicResponsiveListItemFixedColumn); this.#playlist_item_data = { video_id: data?.playlistItemData?.videoId || null, @@ -109,8 +113,8 @@ class MusicResponsiveListItem extends YTNode { 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); + this.menu = Parser.parseItem(data.menu, Menu); + this.overlay = Parser.parseItem(data.overlay, MusicItemThumbnailOverlay); } #parseOther() { diff --git a/src/parser/classes/MusicTwoRowItem.ts b/src/parser/classes/MusicTwoRowItem.ts index 69db4536..1848db27 100644 --- a/src/parser/classes/MusicTwoRowItem.ts +++ b/src/parser/classes/MusicTwoRowItem.ts @@ -5,6 +5,9 @@ import Text from './misc/Text'; import TextRun from './misc/TextRun'; import Thumbnail from './misc/Thumbnail'; import NavigationEndpoint from './NavigationEndpoint'; +import MusicItemThumbnailOverlay from './MusicItemThumbnailOverlay'; +import Menu from './menus/Menu'; + import { YTNode } from '../helpers'; class MusicTwoRowItem extends YTNode { @@ -118,8 +121,8 @@ class MusicTwoRowItem extends YTNode { } this.thumbnail = Thumbnail.fromResponse(data.thumbnailRenderer.musicThumbnailRenderer.thumbnail); - this.thumbnail_overlay = Parser.parse(data.thumbnailOverlay); - this.menu = Parser.parse(data.menu); + this.thumbnail_overlay = Parser.parseItem(data.thumbnailOverlay, MusicItemThumbnailOverlay); + this.menu = Parser.parseItem(data.menu, Menu); } } diff --git a/src/parser/classes/NavigationEndpoint.ts b/src/parser/classes/NavigationEndpoint.ts index 7e83761a..0fbea7c5 100644 --- a/src/parser/classes/NavigationEndpoint.ts +++ b/src/parser/classes/NavigationEndpoint.ts @@ -245,6 +245,10 @@ class NavigationEndpoint extends YTNode { switch (name) { case 'browseEndpoint': return '/browse'; + case 'watchEndpoint': + return '/player'; + case 'watchPlaylistEndpoint': + return '/next'; } } diff --git a/src/parser/classes/PlayerOverlay.ts b/src/parser/classes/PlayerOverlay.ts index 0d61f304..cda4e3f9 100644 --- a/src/parser/classes/PlayerOverlay.ts +++ b/src/parser/classes/PlayerOverlay.ts @@ -14,6 +14,8 @@ class PlayerOverlay extends YTNode { share_button; add_to_menu; fullscreen_engagement; + actions; + browser_media_session; constructor(data: any) { super(); @@ -22,6 +24,8 @@ class PlayerOverlay extends YTNode { this.share_button = Parser.parseItem