I've listened to 6,343 hours of music on Spotify since April 2012. That's 12,371 artists and 114,858 plays. I know this because Spotify will send you your whole listening history if you ask, and I built Liner Notes on Release Wave to turn that file into a page. Mine is here.
You drop the zip on the upload page and get hours per year, top artists per year, genres over time, what you're into now, and the artists you've drifted away from. The file never reaches the server.

Why an export and not "Connect Spotify"
Spotify's API only returns your last 50 plays. That's enough for a "now playing" widget, but it can't tell you what you were listening to in 2014.
The Extended Streaming History export can. Every stream you've ever played, with a timestamp, how long it played, and the track. You request it under Privacy settings in your Spotify account and it arrives by email, within 30 days according to Spotify.

That wait is the worst part of the product, and I can't fix it. Most people who land on the page don't have the file yet, so the landing page shows a sample listener and offers to email you a reminder in a week.
Parsing in a Web Worker
The export has more in it than music. Every row carries an IP address and a country, which is a record of where you've been for a decade. I didn't want that on my server, even for a second.
So a Web Worker (a background thread in the browser) does all the work. It unzips the file, reads only the audio history, and picks fields by name. The IP and country fields are never read at all. It drops podcasts, drops anything played in a private session, and removes duplicate rows. Spotify's export had 1,485 of them in mine. Then it builds the stats: a "play" is anything you listened to for 30 seconds or more, and "hours" is every second streamed.
What gets posted to the server is the totals and an index of artists. No individual plays, ever. For my 14 years, that's about 1.3 MB.
The artist index turned out to be about 95% of that. I first stored it in Postgres, then moved it to Vercel Blob before launch, because at 10,000 users that's gigabytes of JSON in the main database, and only the search box reads it. The page itself renders from a small slice kept with the stats.

There's also a "Hide when I listen" switch. It removes the time-of-day heatmap and rounds every date on the page to the month. The rounding happens on the server before the page is built, so the exact dates never go out to the browser.

I leave mine off. Apparently I peak at 1pm on Saturdays.
Genres over time
Genres come from Release Wave's own catalog first, since plenty of the artists people listen to already have a page there. Everyone else gets tagged by a daily job that asks Last.fm, about 500 artists a day. A big upload fills in over a few days, and the page picks the new tags up without a re-upload.
Every artist with an hour or more of listening gets tagged. For me that's 1,137 artists, and they cover 76% of my hours.

The page splits my listening into Side A (electronic) and Side B (guitars), which is about right. Trance peaked in 2012. Metalcore is having a moment this year.

Adding Last.fm without counting twice
Spotify isn't everything I listen to. I scrobble to Last.fm too (it logs what you play from any app), so I added a field on the upload page: type your Last.fm username and the browser pulls your scrobbles straight from Last.fm. They never go through Release Wave either.
The hard part is that most of those scrobbles are the same listens Spotify already has. Last.fm doesn't say which app sent a scrobble, so the only way to match them is by artist, track and time. And the times don't line up: Spotify records when a stream ended, Last.fm records when it started.
So the merge normalizes both names (strips "feat." and "2005 Remaster" style suffixes), shifts Spotify back by the play length, and matches each scrobble to its nearest Spotify play in three passes: within 2 minutes, then 10, then 60.
I stopped at 60 minutes because I tested it. I shifted all my scrobbles by three to five days, so every match had to be a coincidence, and re-ran the passes. At ±60 minutes that gave 40 to 105 fake matches. At ±6 hours it gave 226 to 300, which is mostly noise.
On my data, 10,319 of 14,405 scrobbles matched a Spotify play and got dropped. The other 4,086 were added. Some of those are a run of Ghøstkid with no Spotify rows at all, which means I was playing it somewhere else.

An assistant can read it
The last piece shipped today. Release Wave has a public MCP endpoint (Model Context Protocol, the way assistants like Claude call outside tools), and it now has five Liner Notes tools. An assistant can pull someone's summary, look up one artist in their history, search it, or open a single year or month.
My favorite is match_concerts. You give it a list of performers, say a festival lineup, and it ranks them against a listener's taste. I gave it a few made-up nights:
Lane 8 known · recent 63.1h #1 last heard 2026-06-12
Knocked Loose known · recent 17.4h #37
Seven Lions known · lapsed 24.9h #15 last heard 2024-05-27
Ben Böhmer known · past 28.2h #9"Lapsed" is the useful one. Seven Lions was in my top 15 and I haven't played them in over two years, which is exactly the show I'd forget to buy tickets for. Artists I've never played come back with evidence instead, like which of their Last.fm similar artists I already listen to.
The tools follow the same rules as the page. Unlisted pages only answer to the exact username, and "Hide when I listen" still rounds the dates.
My page is at releasewave.com/liner-notes/andrew. Search it for a band you like and see if I got there first. If you want your own, start here and request the export today, since it takes a while to show up.