@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
JavaScript
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'} (/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 };