UNPKG

ym-api

Version:

A Node.js wrapper for the Yandex.Music API (Unofficial) http://music.yandex.ru

525 lines (462 loc) 14.1 kB
import { authRequest, apiRequest, directLinkRequest } from "./PreparedRequest"; import fallbackConfig from "./config"; import HttpClient from "./HttpClient"; import { parseStringPromise } from "xml2js"; import crypto from "crypto"; import { ApiConfig, ApiInitConfig, InitResponse, GetGenresResponse, SearchResponse, Playlist, GetTrackResponse, GetTrackSupplementResponse, GetTrackDownloadInfoResponse, ObjectResponse, GetFeedResponse, GetAccountStatusResponse, Track, TrackId, HttpClientInterface, ApiUser, SearchOptions, ConcreteSearchOptions, SearchAllResponse, SearchArtistsResponse, SearchTracksResponse, SearchAlbumsResponse, AlbumId, Album, AlbumWithTracks, FilledArtist, Artist, ArtistId, ArtistTracksResponse, LikedTracks, } from "./types"; export default class YMApi { private user: ApiUser = { password: "", token: "", uid: 0, username: "", }; constructor( private httpClient: HttpClientInterface = new HttpClient(), private config: ApiConfig = fallbackConfig ) {} private getAuthHeader(): { Authorization: string } { return { Authorization: `OAuth ${this.user.token}`, }; } /** * Authentication */ async init(config: ApiInitConfig): Promise<InitResponse> { // Skip auth if access_token and uid are present if (config.access_token && config.uid) { this.user.token = config.access_token; this.user.uid = config.uid; return { access_token: config.access_token, uid: config.uid, }; } if (!config.username || !config.password) { throw new Error( "username && password || access_token && uid must be set" ); } this.user.username = config.username; this.user.password = config.password; const data = (await this.httpClient.post( authRequest().setPath("/1/token").setBodyData({ grant_type: "password", username: this.user.username, password: this.user.password, client_id: this.config.oauth.CLIENT_ID, client_secret: this.config.oauth.CLIENT_SECRET, }) )) as ObjectResponse; this.user.token = data.access_token; this.user.uid = data.uid; return data as InitResponse; } /** * GET: /account/status * Get account status for curren user */ getAccountStatus(): Promise<GetAccountStatusResponse> { const request = apiRequest() .setPath("/account/status") .addHeaders(this.getAuthHeader()); return this.httpClient.get(request) as Promise<GetAccountStatusResponse>; } /** * GET: /feed * Get the user's feed */ getFeed(): Promise<GetFeedResponse> { const request = apiRequest() .setPath("/feed") .addHeaders(this.getAuthHeader()); return this.httpClient.get(request) as Promise<GetFeedResponse>; } /** * GET: /genres * Get a list of music genres */ getGenres(): Promise<GetGenresResponse> { const request = apiRequest() .setPath("/genres") .addHeaders(this.getAuthHeader()); return this.httpClient.get(request) as Promise<GetGenresResponse>; } /** * GET: /search * Search artists, tracks, albums. */ search(query: string, options: SearchOptions = {}): Promise<SearchResponse> { const type = !options.type ? "all" : options.type; const page = String(!options.page ? 0 : options.page); const nococrrect = String( options.nococrrect == null ? false : options.nococrrect ); const request = apiRequest() .setPath("/search") .addHeaders(this.getAuthHeader()) .setQuery({ type, text: query, page, nococrrect, }); if (options.pageSize !== void 0) { request.addQuery({ pageSize: String(options.pageSize) }); } return this.httpClient.get(request) as Promise<SearchResponse>; } searchArtists( query: string, options: ConcreteSearchOptions = {} ): Promise<SearchArtistsResponse> { return this.search(query, { ...options, type: "artist", }) as Promise<SearchArtistsResponse>; } searchTracks( query: string, options: ConcreteSearchOptions = {} ): Promise<SearchTracksResponse> { return this.search(query, { ...options, type: "track", }) as Promise<SearchTracksResponse>; } searchAlbums( query: string, options: ConcreteSearchOptions = {} ): Promise<SearchAlbumsResponse> { return this.search(query, { ...options, type: "album", }) as Promise<SearchAlbumsResponse>; } searchAll( query: string, options: ConcreteSearchOptions = {} ): Promise<SearchAllResponse> { return this.search(query, { ...options, type: "all", }) as Promise<SearchAllResponse>; } /** * GET: /users/[user_id]/playlists/list * Get a user's playlists. */ getUserPlaylists( user: number | string | null = null ): Promise<Array<Playlist>> { const uid = [null, 0, ""].includes(user) ? this.user.uid : user; const request = apiRequest() .setPath(`/users/${uid}/playlists/list`) .addHeaders(this.getAuthHeader()); return this.httpClient.get(request) as Promise<Array<Playlist>>; } /** * GET: /users/[user_id]/playlists/[playlist_kind] * Get a playlist without tracks */ getPlaylist( playlistId: number, user: number | string | null = null ): Promise<Playlist> { const uid = [null, 0, ""].includes(user) ? this.user.uid : user; const request = apiRequest() .setPath(`/users/${uid}/playlists/${playlistId}`) .addHeaders(this.getAuthHeader()); return this.httpClient.get(request) as Promise<Playlist>; } /** * GET: /users/[user_id]/playlists * Get an array of playlists with tracks */ getPlaylists( playlists: Array<number>, user: number | string | null = null, options: { mixed?: boolean; "rich-tracks"?: boolean } = {} ): Promise<Array<Playlist>> { const uid = [null, 0, ""].includes(user) ? this.user.uid : user; const kinds = playlists.join(); const mixed = String(options.mixed == null ? false : options.mixed); const richTracks = String( options["rich-tracks"] == null ? false : options["rich-tracks"] ); const request = apiRequest() .setPath(`/users/${uid}/playlists`) .addHeaders(this.getAuthHeader()) .setQuery({ kinds, mixed, "rich-tracks": richTracks, }); return this.httpClient.get(request) as Promise<Array<Playlist>>; } /** * POST: /users/[user_id]/playlists/create * Create a new playlist */ createPlaylist( name: string, options: { visibility?: "public" | "private" } = {} ): Promise<Playlist> { const visibility = !options.visibility ? "private" : options.visibility; const request = apiRequest() .setPath(`/users/${this.user.uid}/playlists/create`) .addHeaders(this.getAuthHeader()) .setBodyData({ title: name, visibility, }); return this.httpClient.post(request) as Promise<Playlist>; } /** * POST: /users/[user_id]/playlists/[playlist_kind]/delete * Remove a playlist */ removePlaylist(playlistId: number): Promise<"ok" | string> { const request = apiRequest() .setPath(`/users/${this.user.uid}/playlists/${playlistId}/delete`) .addHeaders(this.getAuthHeader()); return this.httpClient.post(request) as Promise<"ok" | string>; } /** * POST: /users/[user_id]/playlists/[playlist_kind]/name * Change playlist name */ renamePlaylist(playlistId: number, name: string): Promise<Playlist> { const request = apiRequest() .setPath(`/users/${this.user.uid}/playlists/${playlistId}/name`) .addHeaders(this.getAuthHeader()) .setBodyData({ value: name, }); return this.httpClient.post(request) as Promise<Playlist>; } /** * POST: /users/[user_id]/playlists/[playlist_kind]/change-relative * Add tracks to the playlist */ addTracksToPlaylist( playlistId: number, tracks: Array<{ id: number; albumId: number }>, revision: number, options: { at?: number } = {} ): Promise<Playlist> { const at = !options.at ? 0 : options.at; const request = apiRequest() .setPath( `/users/${this.user.uid}/playlists/${playlistId}/change-relative` ) .addHeaders(this.getAuthHeader()) .setBodyData({ diff: JSON.stringify([ { op: "insert", at, tracks: tracks, }, ]), revision: String(revision), }); return this.httpClient.post(request) as Promise<Playlist>; } /** * POST: /users/[user_id]/playlists/[playlist_kind]/change-relative * Remove tracks from the playlist */ removeTracksFromPlaylist( playlistId: number, tracks: Array<{ id: number; albumId: number }>, revision: number, options: { from?: number; to?: number } = {} ): Promise<Playlist> { const from = !options.from ? 0 : options.from; const to = !options.to ? tracks.length : options.to; const request = apiRequest() .setPath( `/users/${this.user.uid}/playlists/${playlistId}/change-relative` ) .addHeaders(this.getAuthHeader()) .setBodyData({ diff: JSON.stringify([ { op: "delete", from, to, tracks, }, ]), revision: String(revision), }); return this.httpClient.post(request) as Promise<Playlist>; } /** * GET: /tracks/[track_id] * Get an array of playlists with tracks */ getTrack(trackId: TrackId): Promise<GetTrackResponse> { const request = apiRequest() .setPath(`/tracks/${trackId}`) .addHeaders(this.getAuthHeader()); return this.httpClient.get(request) as Promise<GetTrackResponse>; } /** * GET: /tracks/[track_id] * Get single track */ async getSingleTrack(trackId: TrackId): Promise<Track> { const tracks = await this.getTrack(trackId); if (tracks.length !== 1) { throw new Error(`More than one result received`); } return tracks.pop() as Track; } /** * GET: /tracks/[track_id]/supplement * Get an array of playlists with tracks */ getTrackSupplement(trackId: TrackId): Promise<GetTrackSupplementResponse> { const request = apiRequest() .setPath(`/tracks/${trackId}/supplement`) .addHeaders(this.getAuthHeader()); return this.httpClient.get(request) as Promise<GetTrackSupplementResponse>; } /** * GET: /tracks/[track_id]/download-info * Get track download information */ getTrackDownloadInfo( trackId: TrackId ): Promise<GetTrackDownloadInfoResponse> { const request = apiRequest() .setPath(`/tracks/${trackId}/download-info`) .addHeaders(this.getAuthHeader()); return this.httpClient.get( request ) as Promise<GetTrackDownloadInfoResponse>; } /** * Get track direct link */ async getTrackDirectLink(trackDownloadUrl: string): Promise<string> { const request = directLinkRequest(trackDownloadUrl); const xml = await this.httpClient.get(request); const parsedXml = await parseStringPromise(xml); const host = parsedXml["download-info"].host[0]; const path = parsedXml["download-info"].path[0]; const ts = parsedXml["download-info"].ts[0]; const s = parsedXml["download-info"].s[0]; const sign = crypto .createHash("md5") .update("XGRlBW9FXlekgbPrRHuSiA" + path.slice(1) + s) .digest("hex"); return `https://${host}/get-mp3/${sign}/${ts}${path}`; } /** * GET: /albums/[album_id] * Get an album */ getAlbum(albumId: AlbumId, withTracks: boolean = false): Promise<Album> { const request = apiRequest() .setPath(`/albums/${albumId}${withTracks ? "/with-tracks" : ""}`) .addHeaders(this.getAuthHeader()); return this.httpClient.get(request) as Promise<Album>; } getAlbumWithTracks(albumId: AlbumId): Promise<AlbumWithTracks> { return this.getAlbum(albumId, true) as Promise<AlbumWithTracks>; } /** * GET: /albums * Get an albums */ getAlbums(albumIds: Array<AlbumId>): Promise<Array<Album>> { const request = apiRequest() .setPath(`/albums`) .setBodyData({ albumIds: albumIds.join() }) .addHeaders(this.getAuthHeader()); return this.httpClient.post(request) as Promise<Array<Album>>; } /** * GET: /artists/[artist_id] * Get an artist */ getArtist(artistId: ArtistId): Promise<FilledArtist> { const request = apiRequest() .setPath(`/artists/${artistId}`) .addHeaders(this.getAuthHeader()); return this.httpClient.get(request) as Promise<FilledArtist>; } /** * GET: /artists * Get an artists */ getArtists(artistIds: Array<ArtistId>): Promise<Array<Artist>> { const request = apiRequest() .setPath(`/artists`) .setBodyData({ artistIds: artistIds.join() }) .addHeaders(this.getAuthHeader()); return this.httpClient.post(request) as Promise<Array<Artist>>; } /** * GET: /artists/[artist_id]/tracks * Get tracks by artist id */ getArtistTracks( artistId: ArtistId, options: SearchOptions = {} ): Promise<ArtistTracksResponse> { const page = String(!options.page ? 0 : options.page); const request = apiRequest() .setPath(`/artists/${artistId}/tracks`) .addHeaders(this.getAuthHeader()) .setQuery({ page, }); if (options.pageSize !== void 0) { request.addQuery({ pageSize: String(options.pageSize) }); } return this.httpClient.get(request) as Promise<ArtistTracksResponse>; } getLikedTracks(user: number | string | null = null) { const uid = [null, 0, ""].includes(user) ? this.user.uid : user; const request = apiRequest() .setPath(`/users/${uid}/likes/tracks`) .addHeaders(this.getAuthHeader()) return this.httpClient.get(request) as Promise<LikedTracks>; } }