Building a Last.fm Exporter
The itch
I've been scrobbling to Last.fm since about 2008. That's nearly two decades of listening data: every track, every timestamp, a weirdly intimate diary of moods and phases. I wanted a way to pull it all out without installing anything or handing my credentials to some third-party tool.
So I built one. It lives on my site at /random/lastfm and it's entirely client-side: no backend, no database, just your browser talking directly to the Last.fm API.
How it works
Last.fm's user.getrecenttracks endpoint returns paginated scrobble data. You give it a username and it hands back up to 200 tracks per page. For someone like me with north of 100,000 scrobbles, that's a few hundred pages of requests.
The exporter fires off requests sequentially with a 200ms delay between each one. This keeps us well within Last.fm's rate limits while still being fast enough that you're not waiting around all day.
while (currentPage.value < (numPages.value ?? 0)) {
if (signal.aborted) break;
await sleep(200);
currentPage.value++;
await retrieveTracks(signal);
}Cancellation
One thing that bugged me about the first version was that navigating away from the page would leave hundreds of in-flight or queued requests hanging around. Vue's onBeforeUnmount hook paired with an AbortController sorts that out cleanly:
const abortController = new AbortController();
// pass the signal to every fetch call
await $fetch(url, { signal: abortController.signal });
// tear it all down when the component unmounts
onBeforeUnmount(() => {
abortController.abort();
});Every pending $fetch call receives the abort signal and terminates immediately. No zombie requests, no wasted bandwidth. There's also a manual cancel button so you can stop mid-export if you've changed your mind or just want the first few thousand tracks.
The global toast notification shows a live percentage and has its own cancel button too, so you can navigate elsewhere on the site and still kill the export from the corner of your screen.
CSV export
Once the export finishes (or after cancelling partway through), you can download everything as a CSV. Each row contains the artist, track name, album, and scrobble date.
One thing I had to be careful about was CSV injection. Spreadsheet applications like Excel will happily execute formulas if a cell starts with =, +, -, or @. If someone has a track called =HYPERLINK("https://evil.example") in their library, that shouldn't become a live formula when you open the CSV. Every cell gets sanitised before export, with formula-triggering characters prefixed with a single quote to neutralise them.
function escapeCsvCell(value: string): string {
if (/^[=+\-@\t\r]/.test(value)) {
value = `'${value}`;
}
if (value.includes('"') || value.includes(",") || value.includes("\n")) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}Security considerations
Since this runs entirely in the browser and hits a third-party API, the attack surface is small, but there are still things worth getting right:
- Username validation. Last.fm usernames are 2 to 15 characters, alphanumeric plus hyphens and underscores. The input is validated against this pattern before any request fires, and then
encodeURIComponent'd before interpolation into the URL. No room for query string injection or URL manipulation. - API key exposure. The Last.fm API key is public by design. It's a free key for read-only access to public scrobble data. No OAuth, no secrets.
- CSV injection. Covered above. Any cell starting with a formula character gets escaped.
- No server-side state. Nothing is stored, logged, or forwarded. The browser makes the requests, holds the data in memory, and generates the CSV locally.
Progress
The original version just showed raw page counts. Not particularly useful when you don't know how many pages there are or what that means in terms of actual tracks. The new version has a proper progress bar that fills as tracks come in, plus a percentage and a running total. The global toast mirrors the percentage so you can see progress even after scrolling down.
Try it
Head over to /random/lastfm and punch in a username. Mine's vohzd if you want to see what 18 years of questionable music taste looks like.
