3. The Spotify Side
All Spotify contact goes through one class: src/services/spotifyService.ts. It does five things: authenticate, create a playlist, read a playlist, search, and add tracks.
Authentication: the bit everyone gets wrong once
Spotify uses OAuth 2.0. There are three kinds of credential:
| Credential | What it is | Lifetime | Where it lives |
|---|---|---|---|
| Client ID + secret | Identifies the app you registered in the Spotify developer dashboard | Until you rotate them | settings.json |
| Refresh token | Proves you (the user) granted the app access to your playlists | Long-lived, until revoked | settings.json |
| Access token | What actually goes on each API call (Authorization: Bearer …) |
1 hour | Memory only |
The refresh token is obtained once, by hand. You log in through the
browser using the Authorization Code flow, Spotify redirects back to
redirectUri with a code, and the code is exchanged for a refresh token. After
that, the app trades the refresh token for a fresh access token whenever it
needs one. No browser is involved again.
(Small real-world detail: Spotify stopped accepting localhost as a redirect
URI, so the example config uses http://127.0.0.1:8888/callback. There's a
commit for it.)
Bug #16: the token that died after an hour
The first version called authenticate() once, at startup. The ADR had said
the library "manages token refresh automatically". It doesn't.
spotify-web-api-node gives you a refreshAccessToken() method, and calling
it is your job.
Development never showed the bug, because the manual scripts authenticate
right before they work. In production, though, the process starts on, say,
Sunday, and the Tuesday 00:00 job runs 50 hours later. Every Spotify call
would have failed with 401. It was caught in review before go-live
(#16).
The fix uses two layers, and the pattern is worth knowing by name:
private async callSpotify<T>(request: () => Promise<T>): Promise<T> {
// 1. Proactive: refresh if we're within 5 minutes of expiry
if (this.tokenExpiresAt !== null && Date.now() >= this.tokenExpiresAt - TOKEN_REFRESH_MARGIN_MS) {
await this.authenticate();
}
try {
return await request();
} catch (error) {
// 2. Reactive: if Spotify still says 401, refresh once and retry once
if (status !== 401) throw error;
await this.authenticate();
return request();
}
}
There's a third detail: single-flight refresh. If two calls notice expiry
at the same moment, they share one refresh request (refreshInFlight)
instead of firing two.
async authenticate() {
if (!this.refreshInFlight) {
this.refreshInFlight = this.refreshAccessToken().finally(() => { this.refreshInFlight = null; });
}
return this.refreshInFlight;
}
Every public method wraps its request in callSpotify(() => …). That's a
small, clean example of a higher-order function: a function that takes a
function. It adds behaviour (auth handling) around any request without
repeating the logic.
Playlists
- Create:
createMonthlyPlaylist(year, month)makes a private playlist namedBBC 6 Music Oct 2026.- The name is formatted with the
en-USlocale on purpose. Withen-GB, newer ICU data abbreviates September as "Sept", so the dev PC and the server would have disagreed about what September's playlist is called (#30). It's a tiny bug and a great interview story about environment-dependent behaviour.
- The name is formatted with the
- Read:
getPlaylistTrackDetails(id)pages through the playlist 100 at a time and asks only for the fields it needs (items(track(uri,name,artists(name))),next). Asking only for the fields you need cuts the payload size. - Add:
addTracksToPlaylist(id, uris)checks that every URI looks likespotify:track:…, splits the list into batches of 100 (Spotify's limit per call), and retries each batch.
Spotify doesn't let apps create playlist folders, so you file the monthly playlists into a folder by hand.
Search
const query = `track:${title} artist:${artist}`; // + album: if known
client.searchTracks(query, { limit: 20, market: 'GB' })
track:andartist:are field filters. They narrow the search much more than free text would.market: 'GB'keeps results to tracks you can actually play in the UK.- Each result is mapped to our own
SpotifyTrackshape:isAlbum(fromalbum_type),isRemixandisLive(a regex on the title), andpopularity(0–100, Spotify's own score).
What happens next is the matcher's job (guide 4).
Rate limits
When Spotify is overloaded, or you're too chatty, it answers 429 Too Many
Requests with a Retry-After header in seconds. addBatchWithRetry reads
that header and waits exactly that long. That's the right behaviour, because
the server is telling you how long to wait.
But note:
- Other failures retry with a linear delay (1s, 2s), not exponential, despite what CLAUDE.md says.
- Search and playlist reads have no 429 handling at all. A 429 during search turns into a match error for that song, and match errors are silent. See Known flaws 2 and 4.
In practice this app makes about 40–80 calls a week, so it is nowhere near Spotify's limits. That's why this hasn't hurt yet.
Why spotify-web-api-node
The ADR describes it as the "official" wrapper with automatic token refresh and built-in rate limiting. None of those three claims is true. It's a popular community library, not Spotify's own. It doesn't refresh tokens for you (hence #16). And it doesn't handle rate limits (hence the hand-written 429 code). It has also seen little maintenance for several years, so check its npm page before relying on it further.
It's still a reasonable choice: typed methods, pagination parameters, and less
boilerplate than raw fetch. The lesson is about verifying what a
dependency actually does before your design relies on it. That's a good
answer to "tell me about a mistake you made".
| Alternative | Trade-off |
|---|---|
Raw fetch against the Web API |
No dependency, full control. You'd write auth, paging and types yourself (about 150 lines) |
@spotify/web-api-ts-sdk |
Spotify's own TypeScript SDK and the most obvious migration target. Worth evaluating |
Questions you might get asked
"How does a headless process access a user's Spotify account?"
Through OAuth's Authorization Code flow, done once by hand to get a refresh
token. The daemon keeps the refresh token in a chmod 600 config file and
exchanges it for one-hour access tokens as needed. It refreshes proactively 5
minutes before expiry, and reactively on a 401, with concurrent refreshes
collapsed into a single request.
"What happens if Spotify rotates the refresh token?"
The code uses the new one for the rest of the process's life and logs a
warning. It doesn't write it back to settings.json, so after a restart it
would use the old one. Spotify doesn't usually rotate tokens for this flow,
but it's a known gap.
"How do you handle rate limiting?"
On writes, I honour Retry-After on a 429 and retry up to three times. Reads
and searches don't have that yet. At about 80 calls a week it hasn't mattered,
but it's on the list.
"Why private playlists?" They're personal, and it's the safe default. Making one public is a single click in Spotify.