A typed, runtime-validating TypeScript client for the Last.fm API. Scrobbletail covers all 57 currently documented, non-deprecated methods, groups them into nine namespaces, and validates every successful response with TypeBox.
- Complete Last.fm API coverage
- Inferred TypeScript response types
- TypeBox schemas exported for runtime validation
- Stable Last.fm request signing
- Web, desktop, and mobile-session authentication helpers
- ESM-only package for Bun, Node.js 18.17+, and Web API-compatible runtimes
- Fetch-compatible transport powered by
ofetch
Scrobbletail is an independent project. It is not affiliated with, endorsed by, or sponsored by Last.fm or Paramount Global.
- Install
- Get API credentials
- Quick start
- Usage examples
- Authentication
- Authenticated actions
- Scrobbling
- Configuration
- Errors
- Schemas and types
- API coverage
- Development
npm install scrobbletailbun add scrobbletailScrobbletail is ESM-only and includes TypeScript declarations.
Create a Last.fm API application from the Last.fm API account page. Public read methods need an API key. Signed application and user-session methods also need the API secret.
Keep API secrets, session keys, and user passwords out of browser bundles, source control, and logs.
Create a client with an API key and call a namespace method:
import { createScrobbletailClient } from "scrobbletail";
const lastfm = createScrobbletailClient({
apiKey: process.env.LASTFM_API_KEY!,
});
const chart = await lastfm.chart.getTopArtists({ limit: 10 });
for (const artist of chart.artist) {
console.log(artist.name, artist.url);
}Response values use Last.fm's JSON representation. Counts, ranks, booleans, and timestamps returned by Last.fm are generally strings rather than numbers.
const results = await lastfm.track.search("Teardrop", {
artist: "Massive Attack",
limit: 5,
});
for (const track of results.trackmatches.track) {
console.log(`${track.artist} — ${track.name}`);
}Search methods are available for albums, artists, and tracks:
const albums = await lastfm.album.search("Mezzanine", { limit: 5 });
const artists = await lastfm.artist.search("Portishead", { limit: 5 });
const tracks = await lastfm.track.search("Jóga", { artist: "Björk" });Album, artist, and track methods accept an exclusive identifier object. Supply names or an MBID, not both:
const album = await lastfm.album.getInfo({
artist: "Massive Attack",
album: "Mezzanine",
});
const artist = await lastfm.artist.getInfo({
mbid: "10adbe2f-6c87-4cc8-a679-39c13f7b3d63",
});
const track = await lastfm.track.getInfo(
{ artist: "Björk", track: "Jóga" },
{ autocorrect: true, username: "lastfm-user" },
);
console.log(album.name, artist.name, track.listeners);TypeScript rejects identifier objects that mix an MBID with artist, album, or track names.
const [similar, topAlbums, taggedTracks] = await Promise.all([
lastfm.artist.getSimilar({ artist: "Portishead" }, { limit: 10 }),
lastfm.artist.getTopAlbums(
{ artist: "Portishead" },
{ limit: 10, autocorrect: true },
),
lastfm.tag.getTopTracks("trip-hop", { limit: 10 }),
]);
console.log(similar.artist.map((item) => item.name));
console.log(topAlbums.album.map((item) => item.name));
console.log(taggedTracks.track.map((item) => item.name));const recent = await lastfm.user.getRecentTracks("lastfm-user", {
from: new Date("2026-01-01T00:00:00Z"),
to: new Date("2026-02-01T00:00:00Z"),
extended: true,
limit: 50,
});
for (const track of recent.track) {
const playedAt = track.date?.uts ?? "now playing";
console.log(`${track.artist} — ${track.name} (${playedAt})`);
}from and to accept a Date or a non-negative integer Unix timestamp in seconds.
Top-item methods accept Last.fm reporting periods:
const topTracks = await lastfm.user.getTopTracks("lastfm-user", {
period: "1month",
limit: 25,
});
console.log(topTracks.track.map((item) => item.name));Every endpoint accepts signal and custom headers in its final options object:
const result = await lastfm.chart.getTopTracks({
limit: 20,
signal: AbortSignal.timeout(5_000),
headers: {
"x-request-id": crypto.randomUUID(),
},
});Scrobbletail overwrites protected transport headers such as Accept, Content-Type, and User-Agent where required.
Last.fm authorization creates a long-lived session key. Scrobbletail returns the key but never stores it; persistence belongs to your application.
Create an authorization URL, redirect the user to it, then exchange the callback token:
import {
createAuthorizationUrl,
createScrobbletailClient,
} from "scrobbletail";
const apiKey = process.env.LASTFM_API_KEY!;
const apiSecret = process.env.LASTFM_API_SECRET!;
const authorizationUrl = createAuthorizationUrl({
apiKey,
callbackUrl: "https://music.example.com/auth/lastfm/callback",
});
// Redirect the user to authorizationUrl.In the callback handler:
const authClient = createScrobbletailClient({ apiKey, apiSecret });
async function handleLastFmCallback(request: Request) {
const token = new URL(request.url).searchParams.get("token");
if (!token) throw new Error("Last.fm callback did not include a token");
const session = await authClient.auth.getSession(token);
await secureSessionStore.write(session.key);
return session.name;
}The authorization endpoint must use HTTPS. Callback URLs may use HTTP only for local development.
Desktop applications can request a token before opening the authorization page:
const authClient = createScrobbletailClient({ apiKey, apiSecret });
const token = await authClient.auth.getToken();
const authorizationUrl = createAuthorizationUrl({ apiKey, token });
await openInBrowser(authorizationUrl);
await waitForUserApproval();
const session = await authClient.auth.getSession(token);
await secureSessionStore.write(session.key);auth.getMobileSession sends a plaintext password in a signed HTTPS form body. Use it only from a trusted server or native application—never from browser code.
const session = await authClient.auth.getMobileSession(
usernameFromSecureInput,
passwordFromSecureInput,
);Scrobbletail intentionally excludes the deprecated authToken parameter.
Create a signed-session client with an API secret and either a fixed session key or a lazy provider. The provider takes precedence and runs only for methods that require a session.
const lastfm = createScrobbletailClient({
apiKey: process.env.LASTFM_API_KEY!,
apiSecret: process.env.LASTFM_API_SECRET!,
getSessionKey: async () => secureSessionStore.read(),
});
await lastfm.track.love("Massive Attack", "Teardrop");
await lastfm.track.addTags("Massive Attack", "Teardrop", [
"trip-hop",
"favorite",
]);
await lastfm.album.removeTag("Massive Attack", "Mezzanine", "favorite");Tag mutations accept 1–10 tags. Empty lists and larger batches fail locally before a network request.
Four read methods use the active session when their optional user is omitted:
const profile = await lastfm.user.getInfo();
const tags = await lastfm.track.getTags({
artist: "Massive Attack",
track: "Teardrop",
});Pass a user explicitly to use ordinary API-key mode:
const profile = await lastfm.user.getInfo("lastfm-user");
const tags = await lastfm.track.getTags(
{ artist: "Massive Attack", track: "Teardrop" },
{ user: "lastfm-user" },
);The other two methods with this behavior are album.getTags and artist.getTags.
Update the active session user's now-playing track:
const nowPlaying = await lastfm.track.updateNowPlaying({
artist: "Massive Attack",
track: "Teardrop",
album: "Mezzanine",
albumArtist: "Massive Attack",
duration: 330,
});
if (nowPlaying.ignoredMessage.code !== "0") {
console.error(nowPlaying.ignoredMessage["#text"]);
}Submit one scrobble or a batch of up to 50:
const result = await lastfm.track.scrobble([
{
artist: "Portishead",
track: "Roads",
album: "Dummy",
timestamp: new Date("2026-01-10T20:30:00Z"),
},
{
artist: "Björk",
track: "Jóga",
album: "Homogenic",
timestamp: 1_768_077_240,
chosenByUser: true,
},
]);
console.log(
`Accepted ${result["@attr"].accepted}; ignored ${result["@attr"].ignored}`,
);Scrobble timestamps must be valid Date objects or non-negative integer Unix seconds. Every request uses Last.fm's indexed form fields, including single-item submissions. Retry policy and offline persistence are intentionally left to the consuming application.
const lastfm = createScrobbletailClient({
apiKey: process.env.LASTFM_API_KEY!,
apiSecret: process.env.LASTFM_API_SECRET,
sessionKey: sessionKeyFromSecureStorage,
getSessionKey: async () => secureSessionStore.read(),
baseUrl: "https://lastfm-proxy.example/2.0/",
userAgent: "my-music-app/1.0",
timeout: 10_000,
fetch: customFetch,
});| Option | Required | Purpose |
|---|---|---|
apiKey |
Yes | Last.fm application API key sent with every request. |
apiSecret |
For signed methods | Signs application and session requests. Never sent as a request parameter. |
sessionKey |
For session methods | Fixed Last.fm user-session key. |
getSessionKey |
No | Lazy session-key provider; takes precedence over sessionKey. |
baseUrl |
No | HTTPS Last.fm-compatible API root. Defaults to https://ws.audioscrobbler.com/2.0/. |
userAgent |
No | Requested User-Agent value. Defaults to scrobbletail. |
timeout |
No | Request timeout in milliseconds. Omit to leave timeout handling unset. |
fetch |
No | Fetch-compatible transport for proxies, custom networking, or tests. |
Browsers may strip the forbidden User-Agent header. Browser applications that require guaranteed identification should use a custom fetch or an HTTPS proxy.
Undefined request parameters are omitted. 0, false, and empty strings are preserved. Automatic retries are not performed.
import {
LastFmApiError,
ScrobbletailResponseValidationError,
ScrobbletailTransportError,
} from "scrobbletail";
try {
await lastfm.track.love("Massive Attack", "Teardrop");
} catch (error) {
if (error instanceof LastFmApiError) {
console.error(error.code, error.method, error.status, error.message);
} else if (error instanceof ScrobbletailResponseValidationError) {
console.error(error.method, error.issues);
} else if (error instanceof ScrobbletailTransportError) {
console.error(error.method, error.status);
} else {
throw error;
}
}LastFmApiError covers provider errors, including errors returned in an HTTP 200 response. ScrobbletailResponseValidationError means a successful response did not match its endpoint schema. ScrobbletailTransportError reports redacted network and non-provider HTTP failures without retaining signed URLs, request parameters, credentials, or underlying causes.
Every response schema and its inferred type are exported from the package root:
import { Value } from "@sinclair/typebox/value";
import {
TrackInfoSchema,
type Scrobble,
type TrackInfo,
} from "scrobbletail";
const cached: unknown = await loadCachedValue();
if (Value.Check(TrackInfoSchema, cached)) {
const track: TrackInfo = cached;
console.log(track.name);
}
const queuedScrobble = {
artist: "Massive Attack",
track: "Teardrop",
timestamp: new Date(),
} satisfies Scrobble;The same schemas validate live API responses before Scrobbletail returns them.
The client exposes exactly nine namespaces and 57 methods:
| Namespace | Methods | Purpose |
|---|---|---|
album |
6 | Album metadata, tags, and search. |
artist |
10 | Artist metadata, corrections, similarity, charts, tags, and search. |
auth |
3 | Tokens and Last.fm sessions. |
chart |
3 | Global top artists, tags, and tracks. |
geo |
2 | Country and location charts. |
library |
1 | User library artists. |
tag |
7 | Tag metadata, similarity, charts, and weekly ranges. |
track |
12 | Track metadata, tags, love state, search, now playing, and scrobbling. |
user |
13 | Profiles, friends, listening history, personal tags, and charts. |
See API_COVERAGE.md for the exact method inventory and authentication mode of every endpoint.
bun install
bun run typecheck
bun run test
bun run coverage
bun run build
bun run pack:dryPublishing is triggered by a v* Git tag through the npm workflow in .github/workflows/npm-publish.yml.