
Sonaraem
A personal music intelligence system that syncs your Spotify library, classifies every track with an LLM, and auto-generates mood-based playlists — the crate-digging you'd do yourself, automated.
🎧 Sonaraem — Turning a Spotify Library Into Something That Understands Itself
Overview
Sonaraem is a personal music intelligence system: it pulls your entire Spotify library, runs every track through an LLM to understand what it actually is (mood, theme, energy, era, genre — not just the genre tag Spotify gives you), clusters similar tracks together, and generates named playlists it pushes back to your account.
It's a solo-built monorepo with four apps — api, dashboard, web (the public waitlist/marketing site), and admin (waitlist approval) — sharing packages for auth, database, schemas, logging, and tracing. Access is invite-only right now: signup → email confirmation → admin approval → invite → Spotify OAuth → onboarding.
🌟 The Problem
Spotify's own recommendation and "Discover Weekly" logic works on your listening behavior, not on what's already sitting in your library. If you've saved thousands of tracks over a decade, most of them are unsorted — a flat "Liked Songs" pile with no structure. Making a mood-specific playlist means remembering hundreds of songs by hand.
I wanted the equivalent of a friend who's heard your whole collection and can say "these forty tracks are all late-night, minor-key, downtempo" — except automated, and re-run any time the library changes.
🧠 The Pipeline
Sonaraem is built as an eight-stage pipeline, each stage a discrete, resumable Trigger.dev job rather than one monolithic function. Only two of them ever talk to Spotify:
- Sync — pulls the full Spotify saved-tracks + playlists graph for the user
- Lyrics — fetches lyrics for every track from LRCLib
- Classify — every track goes through Groq's
gpt-oss-20bto extract mood, themes, vibe, energy, era, and genre as structured output - Embed — the classification result is turned into a semantic vector via OpenAI's
text-embedding-3-small - Cluster — tracks are grouped by embedding similarity using DBSCAN (density-based clustering — no need to pre-specify how many playlists should come out, unlike k-means)
- Generate — each cluster gets an AI-named playlist (Groq
gpt-oss-120b), with artist-diversity constraints so one artist can't dominate a cluster - Match — tracks synced after the last run are fit into already-generated playlists by embedding similarity, so a playlist doesn't go stale just because clustering only ran once
- Export — playlists are written back to Spotify via the API
Everything from lyrics through match runs entirely against rows already sitting in Postgres — no Spotify calls in between. That split turns out to matter a lot; it's the whole premise behind the platform-constraint story below.
The pipeline runs as background jobs on Trigger.dev v4, not inline in a request — classification alone means hundreds to thousands of LLM calls per library sync, so this has to be a durable, retryable job graph rather than a single API request.
🛠️ Stack
| Layer | Technology |
|---|---|
| Apps | Next.js 16 (App Router), React 19 |
| API | oRPC — type-safe RPC with OpenAPI generation |
| Auth | Better Auth + Spotify OAuth |
| Database | PostgreSQL (Neon) + Drizzle ORM |
| Background jobs | Trigger.dev v4 |
| Classification | Groq gpt-oss-20b |
| Embeddings | OpenAI text-embedding-3-small |
| Clustering | DBSCAN (density-clustering), per-user adaptive eps |
| Playlist generation | Groq gpt-oss-120b |
| Monorepo | pnpm + Turborepo — api, dashboard, web, admin, sharing packages for auth, db, schemas, logging, tracing |
🔁 CI & Background Jobs
Every push and PR runs through GitHub Actions as parallel jobs — lint (Biome), a typecheck+build job, tests, and a separate commit-lint check enforcing conventional commits — so one failing check doesn't block visibility into the others. Trigger.dev tasks deploy to production automatically on merge to main.
Beyond the on-demand pipeline a user triggers by reconnecting or asking for a re-analysis, a Monday-morning cron re-runs organize for every eligible user in the background. On a clean completion it queues a weekly digest email — created/updated playlist counts, tracks organized, and the new playlist names — so nobody has to open the dashboard to notice their library got reorganized.
⚙️ Engineering Challenges (the honest part)
A few things that didn't work on the first attempt — the actual interesting part of building an LLM-in-the-loop data pipeline rather than a CRUD app. Each one below is a real bug, filed, diagnosed, and fixed against a real library:
-
Retry storms under rate limits. Classifying a full library means firing off a lot of concurrent Groq requests. Stacking the Vercel AI SDK's own retry logic (3×) on top of
p-retry(3×) meant a single rate-limited call could balloon into up to 16 actual requests — hitting Groq's tokens-per-minute limit far harder than the original request pattern would have. (sonaraem#99 → fixed in #273, which taught the retry layer to recognize Groq's schema-validation errors as a distinct, split-retryable case instead of blanket-retrying everything.) -
Fan-out cascades. A related failure mode on a 3,353-track library: classification spawned roughly 56 workers, capped at 3 concurrent — but Trigger.dev's
maxAttempts: 2kept respawning failed workers straight back into the same rate limit, visible as a new run firing every ~45 seconds. (sonaraem#100) -
Clustering doesn't scale flat. DBSCAN parameters tuned for a small library produced far too few playlists once the library got large. On a 2,500+ track library, a fixed
epsproduced 2–3 playlists against an expected 25–35. Density-based clustering needseps/min_samplestuned relative to library size, not fixed. (sonaraem#111 → fixed in #329, which auto-selectsepsper user via a k-distance heuristic instead of a constant — see the chart below.) -
Repetitive playlist names. Running the naming model at
temperature=0for determinism backfired — it converged on the same handful of adjectives ("midnight" showed up constantly) across unrelated clusters, regardless of what was actually in them. Determinism traded away variety. (sonaraem#112) -
Partial-failure handling matters more than happy-path code. When the classify stage failed for a subset of tracks, playlist generation silently proceeded as if there was nothing to do — on one run, all 33 classify workers failed, and every downstream stage logged "nothing to do" and reported success. The pipeline said "completed." The user got zero playlists and no error. (sonaraem#101)
Beyond these fixes, a few PRs reflect the kind of infrastructure work a pipeline like this actually needs once it's past the demo stage: moving Spotify sync off the request path into a durable background job (#303), normalizing LLM classification output into its own track_analysis table instead of loose JSON (#278), instrumenting every external API call — Spotify, LRCLib, OpenAI, Groq — for observability (#249), auto-deploying Trigger.dev tasks on merge to main (#276), and most recently, extracting a swappable packages/ai-provider so the LLM client isn't hardcoded to one provider (#316).
One limitation I haven't fixed yet, in the interest of being honest about it: rate limiting is currently in-memory per API process, which means it stops being correct the moment this runs on more than one instance. Fine solo, on the list before it needs to scale.
🔒 Hitting Spotify's Platform Ceiling
The engineering challenges above are the kind you fix and move past. This one I designed around, tested, and then reversed — which is its own kind of engineering decision, and worth writing up honestly rather than quietly editing out of the story.
The constraint
Spotify's Developer Dashboard has a "Dev Mode" that caps an app at 5 total allowlisted users before Spotify grants full production access. Getting past that — Extended Quota Mode — requires a registered business entity, which isn't realistic for a solo side project. That's not a soft limit to grow into later; it's the actual ceiling unless something else is built on top of it.
The idea: rotate the allowlist
Looking at the pipeline above, only sync and export ever touch the Spotify API — the six stages in between run entirely against tracks and embeddings already sitting in Postgres. That split made an automation idea look viable: if a user's allowlist slot only needs to be occupied for the few seconds it takes to sync or export, not for the whole multi-minute pipeline run, then 5 slots don't have to mean 5 users forever — they could mean 5 users at a time, cycled through a queue.
I built it. Spotify has no API for managing the Dev Mode allowlist, so a Playwright bot drove the Developer Dashboard directly — add an email, wait for sync or export to finish, remove it again. This was only worth building once I'd confirmed it was safe: revoking allowlist access blocks new /authorize attempts but doesn't revoke a refresh token already issued, so a user's session survives their slot being freed for someone else. On top of that went a slot/queue table, a priority tier for interactive logins over background cron syncs, and a reclaim sweep for slots left occupied by a crashed job. sonaraem#290 has the full design; sonaraem#371 and sonaraem#373 are the queue infrastructure that actually merged; sonaraem#372 and sonaraem#385 are the OAuth-gating hook that wired real logins into it.
What live-testing found
Wiring #385's login gate into a real flow surfaced a constraint Spotify doesn't document anywhere. Allowlist add operations are throttled to roughly 5 per 24 hours, app-wide — not per user, the whole app. Spotify's dashboard returns it inline, on the add call itself:
You can't add more than 5 users to an App in a 24 hour period.
That's a materially different ceiling than the one the rotation design assumed. Every first-time login, every reconnect, every background sync-and-export cycle each does one add — and every one of them draws from the same tiny daily budget. In practice that's roughly 5 login/reconnect/sync events per day, app-wide, not 5 concurrent users. A single day of ordinary usage — a handful of logins plus a couple of scheduled syncs — was enough to exhaust it, which is exactly what happened testing it. sonaraem#390 has the full repro.
The pivot
Rather than keep patching a design built on a wrong assumption about Spotify's actual limits, I made the call to reverse course: freeze the allowlist at a fixed, permanent 4 real users, plus 1 reserved admin seat — that reservation exists so the admin account can always sign in to debug, even at capacity — and delete the churn entirely. The spec: no release after login, no rotation pool, no priority queue, no reclaim sweep. Once an email is added, it stays added, and a 5th sign-up attempt gets a clear "at capacity" message before it ever reaches Spotify's own rejection. sonaraem#392 is that decision, currently what I'm building against; the rotation implementation from #385 is closed unmerged rather than deleted outright, on purpose — it's a working reference worth resurrecting if Spotify ever grants Extended Quota Mode, or if the 24h throttle turns out not to be as fixed as it looked.
That leaves growth to happen the other way. Instead of one instance trying to automate its way past a platform-imposed ceiling, the plan going forward is to make Sonaraem easy enough to self-host that someone can point it at their own Spotify developer app and run it for themselves and their own friends — hitting the same 5-user Dev Mode limit, but on their own infrastructure, for their own circle. It's a smaller ambition than scaling one instance past Spotify's limits, but a more honest one: I measured the actual constraint before designing around it, and the second time, I didn't keep pushing on a wall that had already told me its exact dimensions.
📊 Status
Pre-launch, invite-only, and moving to a permanent cap of 4 real users plus 1 reserved admin seat by decision, not by accident — the rotation-queue infrastructure is being replaced rather than patched further. The full pipeline — sync → lyrics → classify → embed → cluster → generate → match → export — works end-to-end against real libraries, including the failure modes above. What's left before it's worth pointing more people at is landing the fixed-allowlist gate, data-quality tuning (dedup, further clustering refinement, naming variety), a UI pass — the dashboard is being rebuilt on a new design system, which is also what's blocking real product screenshots for this very case study — and a self-hosting guide that's actually written for someone other than me.
Built and maintained solo: 194 commits, 170 pull requests, 220 issues opened over the project's life — small, reviewable PRs rather than large batched commits, which is deliberate; it's the same discipline the fixes above came out of.
🔗 Links
- Repo: github.com/FindMalek/sonaraem
- Live (waitlist): web.sonaraem.com