BBC 6 Music → Spotify Sync: Learning Guide

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 named BBC 6 Music Oct 2026.
    • The name is formatted with the en-US locale on purpose. With en-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.
  • 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 like spotify: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.

const query = `track:${title} artist:${artist}`;   // + album: if known
client.searchTracks(query, { limit: 20, market: 'GB' })
  • track: and artist: 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 SpotifyTrack shape: isAlbum (from album_type), isRemix and isLive (a regex on the title), and popularity (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.