UNPKG

@musicorum/lastfm

Version:

Fully typed [Last.fm](https://last.fm) api client library written and made for Typescript!

586 lines (574 loc) 20.9 kB
import crypto from 'node:crypto'; class LastfmError extends Error { response; error; constructor(response) { super(response.message); this.response = response; this.error = response.error; } } /** * Sources: {@link https://lastfm-docs.github.io/api-docs/codes/} and {@link https://www.last.fm/api/errorcodes} */ var LastfmErrorCode; (function (LastfmErrorCode) { /** * This service does not exist */ LastfmErrorCode[LastfmErrorCode["SERVICE_UNAVAILABLE"] = 2] = "SERVICE_UNAVAILABLE"; /** * No method with that name in this package */ LastfmErrorCode[LastfmErrorCode["INVALID_METHOD"] = 3] = "INVALID_METHOD"; /** * You do not have permissions to access the service */ LastfmErrorCode[LastfmErrorCode["AUTHENTICATION_FAILED"] = 4] = "AUTHENTICATION_FAILED"; /** * This service doesn't exist in that format */ LastfmErrorCode[LastfmErrorCode["INVALID_RESPONSE_FORMAT"] = 5] = "INVALID_RESPONSE_FORMAT"; /** * Your request is missing a required parameter */ LastfmErrorCode[LastfmErrorCode["INVALID_PARAMETER"] = 6] = "INVALID_PARAMETER"; /** * Invalid resource specified */ LastfmErrorCode[LastfmErrorCode["INVALID_RESOURCE"] = 7] = "INVALID_RESOURCE"; /** * Most likely the backend service failed. Please try again. */ LastfmErrorCode[LastfmErrorCode["OPERATION_FAILED"] = 8] = "OPERATION_FAILED"; /** * Invalid session key - Please re-authenticate */ LastfmErrorCode[LastfmErrorCode["INVALID_SERSSION_TOKEN"] = 9] = "INVALID_SERSSION_TOKEN"; /** * You must be granted with a valid key by last.fm */ LastfmErrorCode[LastfmErrorCode["INVALID_API_TOKEN"] = 10] = "INVALID_API_TOKEN"; /** * This service is temporary offline. Try again later. */ LastfmErrorCode[LastfmErrorCode["SERVICE_OFFLINE"] = 11] = "SERVICE_OFFLINE"; /** * Invalid method signature supplied */ LastfmErrorCode[LastfmErrorCode["INVALID_SIGNATURE"] = 13] = "INVALID_SIGNATURE"; /** * This token has not been authorized */ LastfmErrorCode[LastfmErrorCode["UNAUTHORIZED_TOKEN"] = 14] = "UNAUTHORIZED_TOKEN"; /** * The service is temporarily unavailable, please try again. */ LastfmErrorCode[LastfmErrorCode["TEMPORARY_ERROR"] = 16] = "TEMPORARY_ERROR"; /** * User requires to be logged in to use this method * This may be caused when trying to get some user's data with restricted privicy */ LastfmErrorCode[LastfmErrorCode["REQUIRES_LOGIN"] = 17] = "REQUIRES_LOGIN"; /** * This application is not allowed to make requests to the web services */ LastfmErrorCode[LastfmErrorCode["API_KEY_SUSPENDED"] = 26] = "API_KEY_SUSPENDED"; /** * This type of request is no longer supported */ LastfmErrorCode[LastfmErrorCode["DEPRECATED"] = 27] = "DEPRECATED"; /** * Your IP has made too many requests in a short period */ LastfmErrorCode[LastfmErrorCode["RATE_LIMIT_EXCEEDED"] = 29] = "RATE_LIMIT_EXCEEDED"; })(LastfmErrorCode || (LastfmErrorCode = {})); /** * Paginated results for a resource. This can be used to get specific pages or multiple pages */ class PaginatedResult { requester; totalPages; totalResults; perPage; pages = []; constructor(attr, requester) { this.requester = requester; this.totalPages = parseInt(attr.totalPages); this.perPage = parseInt(attr.perPage); this.totalResults = parseInt(attr.total); } /** * Appends contents of a page to this paginated result * @param page The page number of the contents to append * @param items The resources of that page to append * @returns This current paginated result */ appendPage(page, items) { // page index is converted to array index subtracting one number // e.g. page 1 reffers to index 0 this.pages[--page] = items; return this; } /** * Get the contents from a page * @param page The page to get the contents from. Page numbers start from 1 * @returns A list of the resources of that specific page */ getPage(page) { return this.pages[--page]; } /** * Get all contents fetched from this paginated result * @returns All contents of all fetched pages. Note that missing pages will be ignoed */ getAll() { return this.pages.flat(); } /** * Fetches content from a page from the API, if it wasn't fetched yet * @param page The page number to fetch content from. Page numbers start from 1 * @param force This will force to fetch that page, even if it's already fetched * @returns The results from that page. */ async fetchPage(page, force = false) { if (this.pages[page - 1] && !force) { return this.getPage(page); } const results = await this.requester(page); this.appendPage(page, results); return results; } } function parseLastfmImages(images) { return images .filter((i) => !!i['#text']) .map((i) => ({ size: i.size, url: i['#text'] })); } function parseLastfmPagination(original) { return { page: parseInt(original.page), totalPages: parseInt(original.totalPages), perPage: parseInt(original.perPage), total: parseInt(original.total) }; } class User { client; constructor(client) { this.client = client; } async getInfo(user) { const original = await this.client.request('user.getInfo', { user }); return { name: original.user.name, realName: original.user.name, age: parseInt(original.user.age), playCount: parseInt(original.user.playcount), country: original.user.country, registered: new Date(parseInt(original.user.registered.unixtime) * 1000), gender: original.user.gender, subscriber: original.user.subscriber === '1', images: parseLastfmImages(original.user.image), url: original.user.url }; } async getRecentTracks(user, params) { const stringParams = { user, limit: (params?.limit ?? 50).toString(), page: (params?.page ?? 1).toString(), extended: params?.extended === true ? '1' : '0' }; if (params?.from) { stringParams.from = Math.round(params.from.getTime() / 1000).toString(); } if (params?.to) { stringParams.to = Math.round(params.to.getTime() / 1000).toString(); } const response = await this.client.request('user.getRecentTracks', stringParams); const trackList = Array.isArray(response.recenttracks.track) ? response.recenttracks.track : [response.recenttracks.track]; const tracks = trackList.map((track) => ({ name: track.name, mbid: track.mbid ?? undefined, streamable: track.streamable == '1', artist: { name: track.artist.name || track.artist['#text'], mbid: track.artist.mbid ?? undefined }, images: parseLastfmImages(track.image), album: { name: track.album['#text'], mbid: track.album.mbid ?? undefined }, url: track.url, date: track.date?.uts ? new Date(parseInt(track.date.uts) * 1000) : undefined, nowPlaying: track['@attr']?.nowplaying === 'true', loved: 'loved' in track ? track.loved === '1' : undefined })); return { tracks, attr: response.recenttracks['@attr'] }; } async getRecentTracksPaginated(user, params) { const metadataResponse = await this.getRecentTracks(user, params); const paginated = new PaginatedResult(metadataResponse.attr, async (page) => { const tracks = await this.getRecentTracks(user, { ...params, page }).then((r) => r.tracks); // skip first item if its now playing and not at first page, to prevent duplicates return page !== 1 && tracks[0].nowPlaying ? tracks.slice(1) : tracks; }); paginated.appendPage(params?.page ?? 1, metadataResponse.tracks); return paginated; } async getTopAlbums(user, params) { const response = await this.client.request('user.getTopAlbums', { ...params, user }); const albums = response.topalbums.album.map((a) => ({ name: a.name, artist: a.artist, playCount: parseInt(a.playcount), rank: parseInt(a['@attr'].rank), mbid: a.mbid, images: parseLastfmImages(a.image) })); return { albums, pagination: parseLastfmPagination(response.topalbums['@attr']) }; } async getTopArtists(user, params) { const response = await this.client.request('user.getTopArtists', { ...params, user }); const artists = response.topartists.artist.map((a) => ({ name: a.name, mbid: a.mbid, url: a.url, playCount: parseInt(a.playcount), streamable: a.streamable === '1', rank: parseInt(a['@attr'].rank), images: parseLastfmImages(a.image) })); return { artists, pagination: parseLastfmPagination(response.topartists['@attr']) }; } async getTopTracks(user, params) { const response = await this.client.request('user.getTopTracks', { ...params, user }); const tracks = response.toptracks.track.map((t) => ({ name: t.name, mbid: t.mbid, url: t.url, playCount: parseInt(t.playcount), artist: t.artist, streamable: t.streamable.fulltrack === '1', rank: parseInt(t['@attr'].rank), images: parseLastfmImages(t.image) })); return { tracks, pagination: parseLastfmPagination(response.toptracks['@attr']) }; } } class Track { client; constructor(client) { this.client = client; } async getInfo(trackName, artistName, params) { const original = await this.client.request('track.getInfo', { track: trackName, artist: artistName, mbid: params?.mbid, autocorrect: params?.autoCorrect === true ? '1' : '0', username: params?.username }); if (!original.track) return undefined; return { user: typeof original.track.userloved === 'string' ? { loved: original.track.userloved === '1', // eslint-disable-next-line @typescript-eslint/no-non-null-assertion playCount: parseInt(original.track.userplaycount) } : undefined, name: original.track.name, mbid: original.track.mbid ?? undefined, url: original.track.url, duration: original.track.duration !== '0' ? parseInt(original.track.duration) : undefined, listeners: parseInt(original.track.listeners), playCount: parseInt(original.track.playcount), artist: { name: original.track.artist.name, mbid: original.track.artist.mbid ?? undefined, url: original.track.artist.url }, album: original.track.album ? { name: original.track.album.title, artist: original.track.album.artist, url: original.track.album.url, images: original.track.album.image ? parseLastfmImages(original.track.album.image) : undefined } : undefined, tags: original.track.toptags?.tag, wiki: original.track.wiki ? { published: new Date(original.track.wiki.published), summary: original.track.wiki.summary, content: original.track.wiki.content } : undefined }; } async love(trackName, artistName, sessionKey) { await this.client.request('track.love', { track: trackName, artist: artistName, sk: sessionKey }, true, true); } async unlove(trackName, artistName, sessionKey) { await this.client.request('track.unlove', { track: trackName, artist: artistName, sk: sessionKey }, true, true); } } const parseAlbumInfoTracks = (tracks) => { return tracks.map((track) => ({ name: track.name, duration: track.duration, artist: { name: track.artist.name, mbid: track.artist.mbid ?? undefined, url: track.artist.url }, url: track.url, rank: track['@attr']?.rank ?? undefined })); }; class Album { client; constructor(client) { this.client = client; } async getInfo(albumName, artistName, params) { const original = await this.client.request('album.getInfo', { album: albumName, artist: artistName, mbid: params?.mbid, autocorrect: params?.autoCorrect === true ? '1' : '0', username: params?.username, lang: params?.biographyLanguage }); if (!original.album) return undefined; return { artist: original.album.artist, images: original.album.image ? parseLastfmImages(original.album.image) : undefined, listeners: parseInt(original.album.listeners), mbid: original.album.mbid !== '' ? original.album.mbid : undefined, name: original.album.name, playCount: parseInt(original.album.playcount), tags: original.album.tags?.tag, tracks: original.album.tracks?.track ? parseAlbumInfoTracks(Array.isArray(original.album.tracks?.track) ? original.album.tracks?.track : [original.album.tracks?.track]) : undefined, url: original.album.url, user: original.album.userplaycount ? { playCount: original.album.userplaycount } : undefined, wiki: original.album.wiki ? { published: new Date(original.album.wiki.published), summary: original.album.wiki.summary, content: original.album.wiki.content } : undefined }; } } const parseSimilarArtists = (similarArtists) => { return similarArtists.map((similarArtist) => ({ name: similarArtist.name, url: similarArtist.url, images: similarArtist.image ? parseLastfmImages(similarArtist.image) : undefined })); }; class Artist { client; constructor(client) { this.client = client; } async getInfo(artistName, params) { const original = await this.client.request('artist.getInfo', { artist: artistName, mbid: params?.mbid, autocorrect: params?.autoCorrect === true ? '1' : '0', username: params?.username, lang: params?.biographyLanguage }); if (!original.artist) return undefined; return { name: original.artist.name, mbid: original.artist.mbid, url: original.artist.url, images: original.artist.image ? parseLastfmImages(original.artist.image) : undefined, streamable: original.artist.streamable === '1', onTour: original.artist.ontour === '1', listeners: parseInt(original.artist.stats.listeners), playCount: parseInt(original.artist.stats.playcount), user: original.artist.stats.userplaycount ? { playCount: parseInt(original.artist.stats.userplaycount) } : undefined, similarArtists: original.artist.similar?.artist ? parseSimilarArtists(original.artist.similar.artist) : undefined, tags: original.artist.tags?.tag, wiki: original.artist.bio ? { published: new Date(original.artist.bio.published), summary: original.artist.bio.summary, content: original.artist.bio.content } : undefined }; } } class Auth { client; constructor(client) { this.client = client; } async getToken() { const original = await this.client.request('auth.getToken', undefined, true); return original.token; } async getSession(token) { const original = await this.client.request('auth.getSession', { token }, true); return { username: original.session.name, key: original.session.key, subscriber: original.session.subscriber === '1' }; } } class Utilities { client; constructor(client) { this.client = client; } /** * Returns the URL to the Last.fm authentication page */ buildDesktopAuthURL(token) { return `https://www.last.fm/api/auth/?api_key=${this.client.apiKey}&token=${token}`; } } /* eslint-disable @typescript-eslint/no-unused-vars */ class LastClient { apiKey; apiSecret; apiUrl = 'https://ws.audioscrobbler.com/2.0'; user = new User(this); track = new Track(this); album = new Album(this); artist = new Artist(this); auth = new Auth(this); utilities = new Utilities(this); headers; constructor(apiKey, apiSecret, appName) { this.apiKey = apiKey; this.apiSecret = apiSecret; if (!apiKey) throw new Error('apiKey is required and is missing'); this.headers = { 'User-Agent': `${appName ?? 'Unspecified App'} (@musicorum/lastfm; github.com/musicorum-app/lastfm)` }; } onRequestStarted(method, params, internalData // eslint-disable-next-line @typescript-eslint/no-empty-function ) { } onRequestFinished(method, params, internalData, response // eslint-disable-next-line @typescript-eslint/no-empty-function ) { } /** * @todo implement signed requests */ async request(method, params, signed = false, write = false) { if (signed && !this.apiSecret) throw new Error('apiSecret is required for signed requests'); params = { ...params, method, api_key: this.apiKey, format: 'json' }; const cleanParams = Object.fromEntries(Object.entries(params).filter(([_, v]) => !!v)); const searchParams = new URLSearchParams(cleanParams); if (signed) { // order cleanParams alphabetically by key const orderedParams = Object.fromEntries(Object.entries(cleanParams).sort(([a], [b]) => a.localeCompare(b))); const signature = Object.entries(orderedParams) .filter(([k]) => k !== 'format') .map(([k, v]) => `${k}${v}`) .join('') + this.apiSecret; const hashedSignature = crypto .createHash('md5') .update(signature) .digest('hex'); searchParams.set('api_sig', hashedSignature); } const queryString = searchParams.toString(); const internalData = {}; this.onRequestStarted(method, cleanParams, internalData); const response = write ? await fetch(`${this.apiUrl}/?format=json`, { method: 'POST', headers: this.headers, body: queryString }) : await fetch(`${this.apiUrl}?${queryString}`, { headers: this.headers }); const data = await response.json(); this.onRequestFinished(method, cleanParams, internalData, data); if (!response.ok) throw new LastfmError(data); return data; } } export { LastClient, LastfmError, LastfmErrorCode };