Files
YouTube.js/deno/src/core/clients/Music.ts
2023-04-29 05:15:47 +00:00

371 lines
12 KiB
TypeScript

import Album from '../../parser/ytmusic/Album.ts';
import Artist from '../../parser/ytmusic/Artist.ts';
import Explore from '../../parser/ytmusic/Explore.ts';
import HomeFeed from '../../parser/ytmusic/HomeFeed.ts';
import Library from '../../parser/ytmusic/Library.ts';
import Playlist from '../../parser/ytmusic/Playlist.ts';
import Recap from '../../parser/ytmusic/Recap.ts';
import Search from '../../parser/ytmusic/Search.ts';
import TrackInfo from '../../parser/ytmusic/TrackInfo.ts';
import AutomixPreviewVideo from '../../parser/classes/AutomixPreviewVideo.ts';
import Message from '../../parser/classes/Message.ts';
import MusicCarouselShelf from '../../parser/classes/MusicCarouselShelf.ts';
import MusicDescriptionShelf from '../../parser/classes/MusicDescriptionShelf.ts';
import MusicQueue from '../../parser/classes/MusicQueue.ts';
import MusicTwoRowItem from '../../parser/classes/MusicTwoRowItem.ts';
import PlaylistPanel from '../../parser/classes/PlaylistPanel.ts';
import SearchSuggestionsSection from '../../parser/classes/SearchSuggestionsSection.ts';
import SectionList from '../../parser/classes/SectionList.ts';
import Tab from '../../parser/classes/Tab.ts';
import Proto from '../../proto/index.ts';
import type { ObservedArray, YTNode } from '../../parser/helpers.ts';
import type { MusicSearchFilters } from '../../types/index.ts';
import { InnertubeError, generateRandomString, throwIfMissing } from '../../utils/Utils.ts';
import type Actions from '../Actions.ts';
import type Session from '../Session.ts';
import {
BrowseEndpoint,
NextEndpoint,
PlayerEndpoint,
SearchEndpoint
} from '../endpoints/index.ts';
import { GetSearchSuggestionsEndpoint } from '../endpoints/music/index.ts';
export default class Music {
#session: Session;
#actions: Actions;
constructor(session: Session) {
this.#session = session;
this.#actions = session.actions;
}
/**
* Retrieves track info. Passing a list item of type MusicTwoRowItem automatically starts a radio.
* @param target - Video id or a list item.
*/
getInfo(target: string | MusicTwoRowItem): Promise<TrackInfo> {
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): Promise<TrackInfo> {
const player_payload = PlayerEndpoint.build({
video_id,
sts: this.#session.player?.sts,
client: 'YTMUSIC'
});
const next_payload = NextEndpoint.build({
video_id,
client: 'YTMUSIC'
});
const player_response = this.#actions.execute(PlayerEndpoint.PATH, player_payload);
const next_response = this.#actions.execute(NextEndpoint.PATH, next_payload);
const response = await Promise.all([ player_response, next_response ]);
const cpn = generateRandomString(16);
return new TrackInfo(response, this.#actions, cpn);
}
async #fetchInfoFromListItem(list_item: MusicTwoRowItem | undefined): Promise<TrackInfo> {
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 player_response = list_item.endpoint.call(this.#actions, {
client: 'YTMUSIC',
playbackContext: {
contentPlaybackContext: {
...{
signatureTimestamp: this.#session.player?.sts
}
}
}
});
const next_response = list_item.endpoint.call(this.#actions, {
client: 'YTMUSIC',
enablePersistentPlaylistPanel: true,
override_endpoint: '/next'
});
const cpn = generateRandomString(16);
const response = await Promise.all([ player_response, next_response ]);
return new TrackInfo(response, this.#actions, cpn);
}
/**
* Searches on YouTube Music.
* @param query - Search query.
* @param filters - Search filters.
*/
async search(query: string, filters: MusicSearchFilters = {}): Promise<Search> {
throwIfMissing({ query });
const response = await this.#actions.execute(
SearchEndpoint.PATH, SearchEndpoint.build({
query, client: 'YTMUSIC',
params: filters.type && filters.type !== 'all' ? Proto.encodeMusicSearchFilters(filters) : undefined
})
);
return new Search(response, this.#actions, Reflect.has(filters, 'type') && filters.type !== 'all');
}
/**
* Retrieves the home feed.
*/
async getHomeFeed(): Promise<HomeFeed> {
const response = await this.#actions.execute(
BrowseEndpoint.PATH, BrowseEndpoint.build({
browse_id: 'FEmusic_home',
client: 'YTMUSIC'
})
);
return new HomeFeed(response, this.#actions);
}
/**
* Retrieves the Explore feed.
*/
async getExplore(): Promise<Explore> {
const response = await this.#actions.execute(
BrowseEndpoint.PATH, BrowseEndpoint.build({
client: 'YTMUSIC',
browse_id: 'FEmusic_explore'
})
);
return new Explore(response);
// TODO: return new Explore(response, this.#actions);
}
/**
* Retrieves the library.
*/
async getLibrary(): Promise<Library> {
const response = await this.#actions.execute(
BrowseEndpoint.PATH, BrowseEndpoint.build({
client: 'YTMUSIC',
browse_id: 'FEmusic_library_landing'
})
);
return new Library(response, this.#actions);
}
/**
* Retrieves artist's info & content.
* @param artist_id - The artist id.
*/
async getArtist(artist_id: string): Promise<Artist> {
throwIfMissing({ artist_id });
if (!artist_id.startsWith('UC') && !artist_id.startsWith('FEmusic_library_privately_owned_artist'))
throw new InnertubeError('Invalid artist id', artist_id);
const response = await this.#actions.execute(
BrowseEndpoint.PATH, BrowseEndpoint.build({
client: 'YTMUSIC',
browse_id: artist_id
})
);
return new Artist(response, this.#actions);
}
/**
* Retrieves album.
* @param album_id - The album id.
*/
async getAlbum(album_id: string): Promise<Album> {
throwIfMissing({ album_id });
if (!album_id.startsWith('MPR') && !album_id.startsWith('FEmusic_library_privately_owned_release'))
throw new InnertubeError('Invalid album id', album_id);
const response = await this.#actions.execute(
BrowseEndpoint.PATH, BrowseEndpoint.build({
client: 'YTMUSIC',
browse_id: album_id
})
);
return new Album(response);
}
/**
* Retrieves playlist.
* @param playlist_id - The playlist id.
*/
async getPlaylist(playlist_id: string): Promise<Playlist> {
throwIfMissing({ playlist_id });
if (!playlist_id.startsWith('VL')) {
playlist_id = `VL${playlist_id}`;
}
const response = await this.#actions.execute(
BrowseEndpoint.PATH, BrowseEndpoint.build({
client: 'YTMUSIC',
browse_id: playlist_id
})
);
return new Playlist(response, this.#actions);
}
/**
* Retrieves up next.
* @param video_id - The video id.
* @param automix - Whether to enable automix.
*/
async getUpNext(video_id: string, automix = true): Promise<PlaylistPanel> {
throwIfMissing({ video_id });
const response = await this.#actions.execute(
NextEndpoint.PATH, { ...NextEndpoint.build({ video_id, client: 'YTMUSIC' }), parse: true }
);
const tabs = response.contents_memo?.getType(Tab);
const tab = tabs?.first();
if (!tab)
throw new InnertubeError('Could not find target tab.');
const music_queue = tab.content?.as(MusicQueue);
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.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.call(this.#actions, {
videoId: video_id,
client: 'YTMUSIC',
parse: true
});
if (!page || !page.contents_memo)
throw new InnertubeError('Could not fetch automix');
return page.contents_memo.getType(PlaylistPanel).first();
}
return playlist_panel;
}
/**
* Retrieves related content.
* @param video_id - The video id.
*/
async getRelated(video_id: string): Promise<ObservedArray<MusicCarouselShelf | MusicDescriptionShelf>> {
throwIfMissing({ video_id });
const response = await this.#actions.execute(
NextEndpoint.PATH, { ...NextEndpoint.build({ video_id, client: 'YTMUSIC' }), parse: true }
);
const tabs = response.contents_memo?.getType(Tab);
const tab = tabs?.matchCondition((tab) => tab.endpoint.payload.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType === 'MUSIC_PAGE_TYPE_TRACK_RELATED');
if (!tab)
throw new InnertubeError('Could not find target tab.');
const page = await tab.endpoint.call(this.#actions, { client: 'YTMUSIC', parse: true });
if (!page.contents)
throw new InnertubeError('Unexpected response', page);
const shelves = page.contents.item().as(SectionList).contents.as(MusicCarouselShelf, MusicDescriptionShelf);
return shelves;
}
/**
* Retrieves song lyrics.
* @param video_id - The video id.
*/
async getLyrics(video_id: string): Promise<MusicDescriptionShelf | undefined> {
throwIfMissing({ video_id });
const response = await this.#actions.execute(
NextEndpoint.PATH, { ...NextEndpoint.build({ video_id, client: 'YTMUSIC' }), parse: true }
);
const tabs = response.contents_memo?.getType(Tab);
const tab = tabs?.matchCondition((tab) => tab.endpoint.payload.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType === 'MUSIC_PAGE_TYPE_TRACK_LYRICS');
if (!tab)
throw new InnertubeError('Could not find target tab.');
const page = await tab.endpoint.call(this.#actions, { client: 'YTMUSIC', parse: true });
if (!page.contents)
throw new InnertubeError('Unexpected response', page);
if (page.contents.item().key('type').string() === 'Message')
throw new InnertubeError(page.contents.item().as(Message).text.toString(), video_id);
const section_list = page.contents.item().as(SectionList).contents;
return section_list.firstOfType(MusicDescriptionShelf);
}
/**
* Retrieves recap.
*/
async getRecap(): Promise<Recap> {
const response = await this.#actions.execute(
BrowseEndpoint.PATH, BrowseEndpoint.build({
client: 'YTMUSIC_ANDROID',
browse_id: 'FEmusic_listening_review'
})
);
return new Recap(response, this.#actions);
}
/**
* Retrieves search suggestions for the given query.
* @param query - The query.
*/
async getSearchSuggestions(query: string): Promise<ObservedArray<YTNode>> {
const response = await this.#actions.execute(
GetSearchSuggestionsEndpoint.PATH,
{ ...GetSearchSuggestionsEndpoint.build({ input: query }), parse: true }
);
if (!response.contents_memo)
throw new InnertubeError('Unexpected response', response);
const search_suggestions_section = response.contents_memo.getType(SearchSuggestionsSection).first();
return search_suggestions_section.contents;
}
}