UNPKG

@musicorum/lastfm

Version:

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

500 lines (489 loc) 17.6 kB
import { LastfmError } from './LastfmError.js'; import crypto from 'node:crypto'; /** * 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(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 };