Self-hosted Spotify companion: a typed Express API over the Spotify Web API plus an Astro web UI (Vue + React islands) with full playback control, an in-browser player, monthly liked-songs playlists and an API explorer.
Built on the official @spotify/web-api-ts-sdk, patched for the Feb 2026 dev-mode API migration (the SDK predates it):
- Playlist create:
POST /me/playlists(old/users/{id}/playlistsreturns 403) - Playlist tracks:
/playlists/{id}/items(old/trackspath returns 403;tracksfield renameditems) - Library save/remove/check:
PUT|DELETE /me/library?uris=…andGET /me/library/contains?uris=…(query params, not body) - Batch multi-gets (
/artists?ids=,/tracks?ids=) removed — fetched individually - Search
limithard-capped at 10 - Removed with no replacement (not used): recommendations, audio features/analysis, related artists, artist top-tracks, browse/new-releases/categories,
/markets
All of the removed/moved endpoints answer 403 "Bad OAuth request (wrong consumer key…)" — misleading; it's the endpoint, not your auth.
- API — Node 22+, Express, TypeScript (ESM), MySQL via knex, Redis (cache, login handoff, sync jobs)
- Web UI — Astro v7 (static output), Vue islands for data/forms, React islands for the player + API explorer, Tailwind v4, nanostores
- Playback — Spotify Web Playback SDK (browser becomes a device) + REST control of any device. Premium required for playback control.
cp .env.example .env # fill in Spotify + DB credentials
npm install
npm run createdb # DROPS and recreates the database — first time only
npm run migrate
npm --prefix web installRegister the exact SPOTIFY_REDIRECT_URI in your Spotify app dashboard.
Note: newly created Spotify apps only accept HTTPS or http://127.0.0.1:… redirect URIs. If your LAN URI is rejected, register http://127.0.0.1:3333/auth/callback and log in once from the server machine — tokens persist, everything else works over LAN afterwards.
npm run dev # API on http://0.0.0.0:3333 (tsx watch)
npm run web:dev # UI on http://0.0.0.0:5432 (Astro dev)Production: npm run build && npm run web:build && npm start — Express serves the built UI from web/dist on port 3333 (single origin, set FRONTEND_URL to the API origin and rebuild the UI with empty PUBLIC_API_BASE).
- UI →
GET /auth/authorize/:userId→ Spotify consent (includesstreaming+ playback scopes) - Spotify →
GET /auth/callback→ tokens stored in MySQL → redirect to UI with a one-time code (2 min TTL, api_token never in a URL) - UI →
POST /auth/exchange→{userId, api_token}→ stored in localStorage, sent asX-API-Key
All routes (except /auth/*) need X-API-Key. Trusted consumers may instead send X-Application-Id + X-User-Id.
| Area | Routes |
|---|---|
| Auth | GET /auth/authorize/:userId, GET /auth/callback, POST /auth/exchange, GET /auth/token (Web Playback SDK token) |
| Profile | GET /me, DELETE /me |
| Library | GET /me/top/tracks, GET /me/top/artists, GET /me/tracks, GET /me/tracks/recent, GET /me/tracks/years, GET /me/tracks/years/:year/months, POST /me/tracks/sync, GET /me/tracks/sync/status |
| Playlists | GET /me/playlists, POST /me/playlists (name or month+year), POST /me/playlists/monthly, GET /playlists/:id |
| Catalog | GET /search, GET /artists/:id, GET /artists/:id/albums |
| Player | GET/PUT /me/player, GET /me/player/devices, GET /me/player/currently-playing, PUT /me/player/play|pause|seek|shuffle|repeat|volume, POST /me/player/next|previous, GET/POST /me/player/queue |
Pagination: ?limit= (max 50) &offset=. Top items: &time_range=short_term|medium_term|long_term. The /explorer page in the web UI documents and fires every endpoint.
- In-browser player needs a DRM-capable browser (Widevine) and a secure context — works on
localhost/HTTPS, not plain LAN HTTP. Device transfer + remote control work everywhere. - Existing users from v1 must re-authorize once (new playback scopes) — the API answers
401 REAUTH_REQUIREDuntil then. npm testruns the vitest suite;npm run lintlints.