It always starts the same way. The Current plays something I’ve never heard, it’s perfect, and by the time I’ve fumbled my phone open the DJ has moved on and the name is gone. I’d been losing songs like that for years.
So I built Rawk On — a Chrome extension that puts a little 🤘 on The Current’s online playlist. Hover any song, click once, and it lands in a dated playlist on your own TIDAL or Spotify. Caught a whole hour worth keeping? One tap grabs the block. This is the story of building it: the clean parts, the part where it nearly fell apart, and the very specific way an AI assistant can lead you confidently down the wrong road.
Conception
The shape of the thing was obvious from the start. A content script that decorates the playlist page with “Add” buttons, a background service worker that talks to a music API, and OAuth to connect your account. Manifest V3, TypeScript, Vite, and CRXJS for the bundling. Nothing exotic.
The one design decision that mattered early: bring your own credentials. Rather than run a shared backend with a single API app (and beg each service for elevated quota), each user creates their own free developer app and pastes in a public client ID. No servers, no accounts, no data leaving the browser. It pushes a little setup friction onto the user, but it means the extension is genuinely private and I never become a custodian of anyone’s listening history. For a tool I mostly built for myself, that trade was easy.
A TIDAL developer account
TIDAL was first. Creating the developer app is straightforward — you register an app, get a client ID, and declare a redirect URI. The interesting part for an extension is the auth flow.
There’s no backend to hold a client secret, so this is a public client: OAuth
Authorization Code with PKCE. Chrome hands you exactly the right primitive —
chrome.identity.launchWebAuthFlow — which opens the provider’s login window and
catches the redirect back to a special URL of the form
https://<extension-id>.chromiumapp.org/.
That <extension-id> is the catch. An unpacked extension’s ID is derived from its
key, and by default that can wander. If the ID changes, your registered redirect URI
breaks and login dies. The fix is to pin a key in the manifest so the dev
extension always loads with the same ID (mine is cdimphoion…), which means one
stable redirect URI I can register once and forget. (Remember that key — it comes
back to bite me at the very end.)
Making it actually work
With auth solved, the rest came together fast. The content script finds each song row on the playlist page and wires an Add button onto it. The service worker takes a title and artist, searches TIDAL, and — this part matters — scores the candidates on title, artist, and album rather than blindly taking the first result. If nothing is a confident match, it adds nothing rather than the wrong song. Each day gets its own playlist named by date, and a small cache remembers what’s already in today’s list so you can’t double-add.
Then came the first real dopamine hit: hover, click, and a song I’d just heard on the radio appeared in my TIDAL library half a second later. That’s the moment a side project stops being a chore and starts being a thing you use. I added an “Add hour” button to bulk-capture an entire hour block, with retry/backoff for rate limits, and called the first version done.
Shifting gears: adding Spotify
Of course I wanted Spotify too. Not everyone’s on TIDAL, and I wanted to be able to switch.
This is where the early architecture paid off. I’d written the TIDAL integration
behind a MusicProvider interface — search, create playlist, add tracks — so adding
Spotify was “just” writing a second implementation. Tokens are stored per provider
(tidalTokens, spotifyTokens), the active service lives in settings, and switching
between them is seamless: no re-login, no reset, each keeps its own daily playlists.
On paper, a clean abstraction. In practice, that clean abstraction is exactly what set the trap.
The trap: two music APIs are not the same
Here’s the thing about building with an AI assistant: it pattern-matches. I had a
working TIDAL provider sitting right there, and when it came time to write the Spotify
one, the AI did the natural thing — it shaped the Spotify calls to look like the TIDAL
calls it already had. Create a playlist by POSTing to a /users/{id}/playlists
endpoint; add songs to a /playlists/{id}/tracks endpoint. Reasonable. Symmetric.
Wrong.
Spotify returned 403 Forbidden on every playlist write.
And then we spent an embarrassingly long time chasing ghosts. Was it Spotify’s dev-mode restrictions? The user-management allowlist? Did the account need Premium? Were the OAuth scopes wrong? Was the token bad? The AI confidently proposed each of these in turn, I dutifully checked each, and each was a dead end. A 403 is a maddeningly generic error, and an assistant will happily generate a plausible cause for it all day long. That’s the failure mode nobody warns you about: AI doesn’t get stuck, it gets confidently lost, and it’ll pull you along with it.
What broke the loop wasn’t another theory. It was going back to the actual Spotify reference docs for creating a playlist and reading them line by line. The endpoints the AI had assumed — mirrored from TIDAL — were deprecated, and deprecated Spotify endpoints don’t 404, they 403. The current calls were different:
- Create playlist:
POST /users/{id}/playlists→POST /me/playlists - Add tracks:
POST /playlists/{id}/tracks→POST /playlists/{id}/items
Two lines of change and it worked instantly. The bug was never auth, or quota, or Premium. It was an assumption — Spotify works like TIDAL — that looked so natural nobody questioned it for hours.
There was a smaller sibling bug that made the same point. After both providers
worked, switching from Spotify back to TIDAL threw 400 INVALID_RESOURCE_ID. The
cause: a shared daily-playlist cache keyed only by date, so a Spotify playlist ID
got handed to TIDAL, which understandably rejected it. The fix was to namespace the
cache key by provider — `${this.id}·${date}` — plus a migration to purge the old
shared entries. Same lesson, smaller blast radius: the two services share a shape,
not an identity, and anywhere I let them blur, it broke.
The takeaway I keep coming back to: an AI pair is phenomenal at the 80% that’s genuinely similar, and it’s most dangerous precisely there — because the 20% that differs hides inside code that looks correct. The thing that saved hours wasn’t a cleverer prompt. It was a human pointing at the authoritative document and saying “read this, specifically.”
Publishing to the Chrome Web Store
Working-on-my-machine and shippable are different sports. Getting Rawk On into the Chrome Web Store meant assembling a small mountain of artifacts: a privacy policy (hosted on my own domain, with a product-specific section that actually describes what the extension does — not a generic template), screenshots at exactly 1280×800 with no alpha channel, a 440×280 small promo tile, a 1400×560 marquee tile, a 128×128 store icon, and listing copy with permission justifications for every host and API.
And then the manifest key came back for its revenge. Remember that pin I added so
the dev extension had a stable ID? The store rejects any upload whose manifest
contains a key field — “key field is not allowed in manifest” — because the store
assigns its own ID. So I now have a tiny pack script that builds, strips the key
from the packaged manifest only, and zips that. Dev build keeps the stable ID; store
build doesn’t.
One last quirk worth knowing if you go down this road: the published extension ID is different from the dev ID, which means the OAuth redirect URI changes after publishing. In a bring-your-own-credentials model that’s fine — the setup docs just tell users to copy the live redirect URI from the extension’s own Settings page rather than hardcoding it.
What I’d tell past me
- A clean abstraction can hide a sharp edge. The
MusicProviderinterface was the right call and the reason the Spotify calls inherited TIDAL’s wrong shape. Symmetry in your code doesn’t imply symmetry in the world. - 403 is a liar. Treat a generic forbidden as “go read the actual endpoint docs,” not as a prompt to brainstorm causes.
- AI gets confidently lost, not stuck. It’s a force multiplier on the parts that rhyme, and a confident misdirection on the parts that only look like they rhyme. The human’s job is to know when to stop theorizing and go to the source.
- Shipping is its own project. Budget real time for privacy policies, image specs, and the small platform gotchas that have nothing to do with your code.
Rawk On is free, with its source on GitHub. I built it because I was tired of losing songs. I’m keeping it because every afternoon, the good ones land where I can find them again.
Rawk on. 🤘
Source & setup: github.com/ry-ops/rawk-on · Privacy: ry-ops.dev/privacy