Smarter playlist management. Analyze, shuffle, organize, and automate your music library.
Apply composable shuffle algorithms to any playlist before saving:
- True Random — Fisher-Yates equal-probability shuffle
- Artist Spread — no two tracks by the same artist play back-to-back
- Genre Spread — groups similar genres together for smoother listening flow
- Chronological Mix — interleaves tracks from different eras
Algorithms are composable — combine Genre Spread + Artist Spread in a single pass.
Set a playlist to automatically reshuffle on a daily, weekly, or monthly schedule. A background cron job handles reshuffling server-side without any manual action.
- Merge — combine multiple playlists into one, with optional duplicate removal
- Split — divide a playlist by genre, artist, era/decade, or audio feature (energy, danceability, valence, tempo, and more). Preview, rename, and rearrange groups before saving
- Duplicate Finder — scan a playlist for duplicate tracks and remove them inline
Every track is enriched with:
- Audio features — energy, danceability, valence, tempo, acousticness, instrumentalness, speechiness (via ReccoBeats)
- Genres — artist-level genre tags (via Last.fm)
- Visualized as donut charts on the playlist detail page
Paste any public Spotify playlist URL to open and analyze it, even if it's not in your library.
Browser → Vite (5173) → React Router → Page component
│
src/api/*.ts ← typed fetch wrappers
│
Express server (3000)
│
refreshTokenMiddleware
(attaches valid access token)
│
┌─────────────────┴──────────────────┐
│ Route Handlers │
│ auth.ts │ playlists.ts │ reshuffle │
└──────────┬──────────────┬──────────┘
│ │
PlatformAdapter Prisma ORM
(Spotify / SoundCloud / Tidal / YouTube) │
│ PostgreSQL
Platform API
Last.fm API
ReccoBeats API
All playlist and reshuffle routes run through server/src/middleware/refreshToken.ts, which auto-refreshes expired tokens and attaches the valid access token to req before any route handler runs.
Spotify's API returns track metadata (name, artist, album) but no audio analysis or genre data. TuneCraft enriches every track on the way out:
GET /playlists/:userId/:playlistId/tracks
│
▼
Fetch raw tracks from Spotify
│
▼
┌────────────────────────────────────────────┐
│ Audio Features │
│ │
│ Check TrackCache (spotifyId / soundcloud │
│ Id / isrc — one OR query, all platforms) │
│ ├── HIT → use cached data │
│ │ └─ ISRC cross-hit? backfill │
│ │ platform ID fire-and-forget │
│ └── MISS → Phase 0: ISRC → spotifyId │
│ (SoundCloud only) │
│ │ │
│ ReccoBeats API │
│ (batches of ≤ 40 tracks) │
│ │ │
│ Upsert TrackCache by ISRC │
│ (links both platform IDs │
│ to a single row) │
└────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────┐
│ Genres │
│ │
│ Check ArtistCache (by artistId) │
│ ├── HIT → use cached tags │
│ └── MISS → fetch per artist │
│ │ │
│ Last.fm API │
│ (artist.getTopTags) │
│ │ │
│ genres empty? → skip cache │
│ genres present? → persist │
│ to ArtistCache │
└───────────────────────────────────────┘
│
▼
Merge audioFeatures + genres onto each track object
│
▼
Return enriched tracks to client
Why two caches? Audio features are keyed per track (stable — a song's BPM doesn't change). Genres are keyed per artist (one artist → many tracks; caching at the artist level avoids redundant Last.fm calls).
Cache write policy:
| TrackCache | ArtistCache | |
|---|---|---|
| API error / 429 | Not cached — retried on next request | Not cached — retried on next request |
| Empty response (no data) | Cached — TTL will trigger a fresh fetch in 90 days | Not cached — retried on every request until Last.fm returns tags |
| Response with data | Cached | Cached |
The asymmetry is intentional: audio features are stable and unlikely to appear after a 200 with no data, so caching the empty response avoids hammering ReccoBeats. Genre tags can be added to Last.fm at any time, so an empty response is never written to the cache — the next request always gets a fresh attempt.
Cross-platform deduplication: TrackCache holds one row per unique recording, not one row per platform track entry. The row is keyed by ISRC when available, so if a song is loaded on Spotify first and later on SoundCloud, ReccoBeats is never called a second time — the existing features are returned immediately and the SoundCloud ID is backfilled onto the existing row.
| TrackCache | ArtistCache | |
|---|---|---|
| Cross-platform read hit | Found via isrc in the OR query |
Found via normalizedName (lowercase artist name) |
| Backfill native ID on hit | tidalId / soundcloudId written to row fire-and-forget |
tidalArtistId / soundcloudArtistId written to row fire-and-forget |
| Secondary cache check | After ISRC → Spotify ID resolution, re-checks DB by spotifyId before calling ReccoBeats — avoids a redundant API call when a Spotify-sourced row exists without an ISRC |
N/A — normalizedName covers the cross-platform hit on the initial read; no secondary check needed |
| Write collision handling | If a spotifyId row exists without an ISRC and a new ISRC resolves to that same ID, the ISRC is merged onto the existing row rather than creating a duplicate |
N/A — upsert key is normalizedName; the same artist from two platforms always lands on one row |
ReccoBeats batch cap: The API accepts up to 40 track IDs per request. Requests are split into chunks of 40 before dispatch.
Prisma JSON columns: audioFeatures is stored as JSON. After retrieval it may deserialize as a string. All consumers guard with:
typeof f === 'string' ? JSON.parse(f) : fapplyShuffle() applies enabled algorithms in a fixed order. Order matters — Artist Spread after Genre Spread produces different results than the reverse.
Input tracks array
│
▼
┌──────────────────────────────────────┐
│ 1. Chronological Mix (if enabled) │
│ Sort by release year, │
│ then interleave eras evenly │
└──────────────────────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ 2. Genre Spread (if enabled) │
│ Group tracks by genre, │
│ round-robin across groups │
└──────────────────────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ 3. Artist Spread (if enabled) │
│ Ensure no adjacent same-artist │
│ pairs using gap insertion │
└──────────────────────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ 4. True Random (mutually exclusive) │
│ Fisher-Yates in-place shuffle │
│ (skipped if any above enabled) │
└──────────────────────────────────────┘
│
▼
Output shuffled tracks array
Two identical copies exist — one server-side for actual saves and the cron job, one client-side for instant preview before the user commits:
| File | Used by |
|---|---|
server/src/lib/shuffleAlgorithms.ts |
Shuffle route, auto-reshuffle cron |
client/src/utils/shuffleAlgorithms.ts |
UI preview (instant, no API call) |
node-cron ──── runs every hour (0 * * * *) ────▶ reshuffleCron.ts
│
Query Playlist table:
autoReshuffle = true AND
nextReshuffleAt ≤ NOW
│
For each matching playlist:
│
┌─────────┴──────────┐
│ │
fetchAllTracksMeta applyShuffle()
(Spotify, no (stored algorithm
enrichment) settings)
│ │
└─────────┬──────────┘
│
Write order to platform
via PlatformAdapter
│
Update DB:
lastReshuffledAt = now
nextReshuffleAt = now + intervalDays
│
Spotify 404?
→ Delete orphaned schedule
lastReshuffledAt is only written by:
- The shuffle route (manual shuffle + save)
- The save route (manual track reorder + save with active schedule)
- The auto-reshuffle cron
The schedule upsert route (POST /reshuffle/schedule) intentionally does not set lastReshuffledAt — creating or updating a schedule is not a shuffle event.
All Spotify-specific code lives behind an interface. Routes never call Spotify directly:
routes/playlists.ts
│
└──▶ getAdapter(platform) ← resolves 'spotify' → SpotifyAdapter, 'tidal' → TidalAdapter, etc.
│
▼
PlatformAdapter interface
┌─────────────────────────────┐
│ getPlaylists(userId) │
│ getTracks(userId, id) │
│ updateTrackOrder(...) │
│ createPlaylist(...) │
│ addTracks(...) │
└─────────────────────────────┘
│
▼
SpotifyAdapter / TidalAdapter / SoundCloudAdapter
server/src/lib/platform/{spotify,tidal,soundcloud}.ts
Adding a new platform means implementing PlatformAdapter and registering it in registry.ts — zero changes to route handlers. TrackCache already has a dedicated column for each platform (spotifyId, soundcloudId, tidalId) — add a new column per platform as adapters are built. Tidal (TidalAdapter) is fully implemented alongside Spotify and SoundCloud.
User clicks "Login with Spotify"
│
▼
GET /auth/login → redirects to Spotify OAuth
│
User grants permission on Spotify
│
▼
GET /auth/callback (Spotify redirects here)
├── Exchange code for access + refresh tokens
├── Upsert User record in DB (cuid as internal ID)
└── Redirect to frontend /callback?userId=...&platformUserId=...
│
▼
Callback.tsx reads URL params
├── localStorage.setItem('userId', ...) ← internal DB cuid
└── localStorage.setItem('platformUserId', ...) ← Spotify user ID
│
▼
Navigate to /dashboard
Every subsequent API call:
├── Passes userId in URL: /playlists/:userId/...
└── refreshTokenMiddleware checks token expiry,
fetches new tokens from the platform if needed,
attaches fresh access token to req.platformToken
localStorage is used (not sessionStorage) so that authentication persists across multiple browser tabs opened from the same origin.
All platform APIs enforce rate limits. requestWithRetry in server/src/lib/requestWithRetry.ts handles this transparently across every adapter:
- Make the request
- If 429 → read
Retry-Afterheader (default 5s, floored at 1s, capped at 120s) - Retry up to 3 times
- On third failure, propagate the error
The 1s floor prevents APIs that send Retry-After: 0 from burning all retry attempts with back-to-back requests inside the same rate-limit window.
┌─────────────────────────────────────────┐
│ User │
│─────────────────────────────────────────│
│ id String (cuid, PK) │
│ platformUserId String │
│ displayName String │
│ email String? │
│ accessToken String │
│ refreshToken String │
│ tokenExpiresAt DateTime │
│ platform Platform (enum) │
│ createdAt DateTime │
│ @@unique([platformUserId, platform]) │
└──────────────────────┬──────────────────┘
│ 1:N
▼
┌─────────────────────────────────────────┐
│ Playlist │
│─────────────────────────────────────────│
│ id String (cuid, PK) │
│ userId String (FK → User) │
│ platformPlaylistId String │
│ name String │
│ autoReshuffle Boolean │
│ intervalDays Int? │
│ algorithms Json? │
│ lastReshuffledAt DateTime? │
│ nextReshuffleAt DateTime? │
│ platform Platform (enum) │
│ @@unique([userId, platformPlaylistId]) │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ TrackCache │
│─────────────────────────────────────────│
│ id String (cuid, PK) │
│ isrc String? (unique) │
│ spotifyId String? (unique) │
│ soundcloudId String? (unique) │
│ tidalId String? (unique) │
│ youtubeId String? (unique) │
│ audioFeatures Json │
│ cachedAt DateTime │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ ArtistCache │
│─────────────────────────────────────────│
│ id String (cuid, PK) │
│ artistId String (unique) │
│ artistName String │
│ normalizedName String? (unique) │
│ spotifyArtistId String? (unique) │
│ tidalArtistId String? (unique) │
│ soundcloudArtistId String? (unique) │
│ youtubeArtistId String? (unique) │
│ genres Json (string[]) │
│ platform Platform (enum) │
│ cachedAt DateTime │
└─────────────────────────────────────────┘
Playlist stores only scheduling metadata — track data is never persisted locally. It is always fetched live from the platform and enriched on the fly via the two cache tables.
TrackCache holds one row per unique recording. The same song on Spotify and SoundCloud shares a single row, linked by ISRC. Platform-specific ID columns (spotifyId, soundcloudId) are added as each adapter is built.
| Layer | Technology |
|---|---|
| Frontend | React, TypeScript, Vite, Tailwind CSS |
| Backend | Node.js, Express, TypeScript |
| Database | PostgreSQL via Prisma ORM |
| Auth | Spotify OAuth 2.0 with automatic token refresh |
| External APIs | Spotify Web API, SoundCloud API, Tidal API (OpenAPI v2), YouTube Data API v3, Last.fm, ReccoBeats |
| Background jobs | node-cron |
- Node.js — latest LTS recommended
- PostgreSQL — local or hosted instance
- Spotify Developer App — create one at developer.spotify.com. You will need a Client ID, Client Secret, and a configured redirect URI
- Tidal Developer App — register at developer.tidal.com. Uses PKCE OAuth 2.0; requires
user.read,collection.read,collection.write,playlists.read,playlists.writescopes - YouTube Developer App — create a project in Google Cloud Console, enable the YouTube Data API v3, and create OAuth 2.0 credentials. Requires
youtube.readonlyandyoutube.force-sslscopes - Last.fm API key — register at last.fm/api
- ReccoBeats API key — for audio features (replaces Spotify's deprecated audio features endpoint)
From the repo root:
npm installCopy the example file and fill in your values:
cp server/.env.example server/.envserver/.env:
# Base URL of this server — redirect URIs for all platforms are derived from it.
# Dev: http://127.0.0.1:3000 | Prod: https://your-api-domain.com
SERVER_URL=http://127.0.0.1:3000
FRONTEND_URL=http://127.0.0.1:5173
PORT=3000
SPOTIFY_CLIENT_ID=your_spotify_client_id
SPOTIFY_CLIENT_SECRET=your_spotify_client_secret
SOUNDCLOUD_CLIENT_ID=your_soundcloud_client_id
SOUNDCLOUD_CLIENT_SECRET=your_soundcloud_client_secret
TIDAL_CLIENT_ID=your_tidal_client_id
TIDAL_CLIENT_SECRET=your_tidal_client_secret
YOUTUBE_CLIENT_ID=your_youtube_client_id
YOUTUBE_CLIENT_SECRET=your_youtube_client_secret
LASTFM_API_KEY=your_lastfm_api_key
LASTFM_SECRET=your_lastfm_secret
DATABASE_URL=postgresql://USER:PASSWORD@HOST:5432/DB_NAME?schema=public
DIRECT_DATABASE_URL=postgresql://USER:PASSWORD@HOST:5432/DB_NAME?schema=publiccd server
npx prisma generate
npx prisma migrate devFrom the repo root:
npm run devThis starts all three processes concurrently:
| Process | URL |
|---|---|
| Backend API | http://127.0.0.1:3000 |
| Frontend | http://127.0.0.1:5173 |
| Prisma Studio | launched automatically |
Health check: GET http://127.0.0.1:3000/health
tunecraft/
├── client/ # React frontend (Vite + TypeScript)
│ └── src/
│ ├── api/ # Typed fetch wrappers (playlists, tracks, reshuffle)
│ ├── components/ # Modals (Shuffle, Split, Merge, Copy, Duplicates), AppFooter, PlatformSwitcherSidebar
│ ├── constants/ # Audio feature keys, labels, chart colours
│ ├── hooks/ # useAnimatedLabel, usePlaylistTracks, usePlaylistActions, useReshuffleSchedule
│ ├── pages/ # Route-level components (Login, Dashboard, PlaylistDetail, Contact, PrivacyPolicy, Callback)
│ └── utils/ # shuffleAlgorithms, splitPlaylist, mergePlaylists, platform helpers
└── server/ # Express backend (Node.js + TypeScript)
└── src/
├── controllers/ # Route handlers by domain: library, discover, tracks, operations
├── lib/
│ ├── crons/ # Auto-reshuffle cron job
│ ├── platform/ # PlatformAdapter interface, SpotifyAdapter, TidalAdapter, SoundCloudAdapter, YouTubeAdapter, registry
│ ├── playlistHelpers.ts # Shared enqueueWrite (write queue) and calculateAverages
│ └── shuffleAlgorithms.ts
├── middleware/ # refreshToken.ts — transparent token refresh
└── routes/ # auth.ts, playlists.ts (route registration only), reshuffle.ts
Repo root:
npm run dev # Start client + server + Prisma Studio concurrentlyserver/:
npm run dev # Start server with hot reload (ts-node + nodemon)
npm run build # Compile TypeScript to dist/
npm start # Run compiled dist/client/:
npm run dev # Start Vite dev server
npm run build # Production build
npm run lint # ESLint
npm run preview # Preview production build locallyDatabase (server/):
npx prisma generate # Regenerate PrismaClient after schema changes
npx prisma migrate dev # Apply migrations in development
npx prisma studio # Open DB GUIAll routes are prefixed with the Express base path. The userId segment is the internal DB cuid stored in localStorage after login.
| Method | Path | Description |
|---|---|---|
GET |
/auth/login?platform=SPOTIFY|TIDAL|SOUNDCLOUD|YOUTUBE |
Redirects to the appropriate platform OAuth / PKCE flow |
GET |
/auth/spotify/callback |
Handles Spotify OAuth redirect, upserts user, redirects to frontend |
GET |
/auth/tidal/callback |
Handles Tidal PKCE callback, exchanges code + verifier, upserts user |
GET |
/auth/soundcloud/callback |
Handles SoundCloud OAuth redirect, upserts user, redirects to frontend |
GET |
/auth/youtube/callback |
Handles Google OAuth redirect, upserts user, redirects to frontend |
| Method | Path | Description |
|---|---|---|
GET |
/playlists/:userId |
List all playlists for user (owned + following) |
GET |
/playlists/:userId/:playlistId/tracks |
Get enriched tracks for a playlist |
PUT |
/playlists/:userId/:playlistId/save |
Persist a new track order to Spotify |
POST |
/playlists/:userId/:playlistId/shuffle |
Shuffle and save to Spotify |
POST |
/playlists/:userId/:playlistId/split |
Create multiple new playlists from grouped tracks |
POST |
/playlists/:userId/merge |
Merge tracks from multiple playlists into one |
POST |
/playlists/:userId/copy |
Copy a playlist to the user's library |
GET |
/playlists/:userId/discover |
Fetch and enrich any public playlist by URL |
| Method | Path | Description |
|---|---|---|
POST |
/reshuffle/:userId/:playlistId/schedule |
Create or update an auto-reshuffle schedule |
DELETE |
/reshuffle/:userId/:playlistId/schedule |
Remove an auto-reshuffle schedule |
GET |
/reshuffle/:userId/:playlistId/schedule |
Get current schedule for a playlist |
See TUNECRAFT_ROADMAP.md for the full feature roadmap, current progress, and technical notes.