Self-hosted listening history and stats: import an Extended streaming history ZIP from your Spotify account (privacy export), then sync new listens from Last.fm only. Stack: Next.js, Prisma, PostgreSQL.
Default UI range: This year (ytd). Use the period control or ?range=all for all time.
Disclaimer: Not affiliated with Spotify. Spotify is a trademark of Spotify AB. The app reads the official account data export (ZIP), not the Spotify Web API.
A template preview at /demo uses the same UI and stats code as /me, but reads synthetic rows in Postgres (isDemo: true) so album and artist images are stored like real data and render reliably.
One-time after clone / schema change:
npm run db:push
npm run db:seed-demoThat inserts ~12 months of sample plays (with artwork URLs). Re-run db:seed-demo anytime to replace demo data. Backfill buttons and /api/backfill-* also apply to demo rows missing art.
| Where | URL |
|---|---|
| Live hosted demo | soundfolio-stats.netlify.app/demo?range=ytd |
| Local dev | http://localhost:3000/demo (optional: ?range=ytd or ?range=1y) |
| Your own deploy | https://<your-domain>/demo |
The real stats UI is under /me (and may require AUTH_KEY when set).
Not strictly impossible to show a preview without Postgres—you could ship in-memory fake rows and duplicate the stats math (no Prisma). This repo doesn’t do that anymore because: one code path with /me, reliable album/artist URLs on real Stream rows, and backfill works the same.
- No database at all: the app is built around
DATABASE_URLfor imports and/me, so a DB-less deploy only makes sense if you only want a static marketing page—not the current Soundfolio app. - Postgres without manual seeding: point
DATABASE_URLat your host, runnpm run db:pushonce, then either runnpm run db:seed-demoyourself or add it to your deploy (e.g. Netlify build step afterprisma generate) so demo rows are inserted automatically—it still uses Postgres, but you don’t have to remember to seed by hand after every clone.
After setup or if you only self-host, run:
npm run remove-demo(bash scripts/remove-demo.sh does the same.) It deletes app/demo/, app/api/demo/, and lib/demo-*.ts, restores /me-only nav and header, resets the root page, removes the Deezer demo image host from next.config.ts, and simplifies ListeningActivity. Then run npm run build to confirm.
Optionally delete demo rows in SQL: DELETE FROM "Stream" WHERE "isDemo" = true; (or keep them; they do not affect /me stats).
You can delete scripts/remove-demo.sh and the remove-demo npm script afterward if you want a minimal tree.
Totals, diversity, and weekly listening (year-to-date).
Busiest hour and weekday, listening by hour and by day, week × hour heatmap.
Ranked by streams for the selected period.
Soundfolio stores every play (Stream rows) in Postgres via Prisma. Demo preview rows are the same shape, with isDemo: true (see Demo).
| What | Why |
|---|---|
DATABASE_URL |
Connection string the app and Prisma use at runtime. Required. |
DIRECT_URL |
Some hosts (e.g. Neon) use a separate URL for migrations; if your host docs don’t mention it, you can set it equal to DATABASE_URL or omit it. |
npm run db:push |
Applies the schema in prisma/schema.prisma to your database (good for first setup and solo projects). |
npm run db:migrate |
Use when you want versioned migrations (teams / production discipline). |
npm run db:generate |
Regenerates the Prisma client after schema changes (also runs during npm run build). |
npm run db:studio |
Opens a local GUI to browse tables. |
Typical cloud Postgres: enable SSL in the URL (often ?sslmode=require). Example: postgresql://USER:PASSWORD@HOST/DB?sslmode=require.
Never commit .env.
You need: Node 20+, PostgreSQL, Last.fm API key + username, and the Spotify privacy ZIP (not the developer API).
git clone https://github.com/olivertransf/Soundfolio.git && cd Soundfolio
npm install
cp .env.example .env- Put your Postgres URL in
DATABASE_URL(andDIRECT_URLif your host requires it). LASTFM_API_KEY+LASTFM_USER— from Last.fm API, and connect your Spotify app to Last.fm so scrobbles show up.npm run db:push(applies schema, includingisDemoonStream).- Optional:
npm run db:seed-demo— fills/demowith sample data (safe alongside a real import later). npm run dev→ Import → upload the ZIP → Sync from Last.fm.
Optional: AUTH_KEY locks /me routes.
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
Yes | PostgreSQL URL. |
DIRECT_URL |
Optional | Often same as DATABASE_URL for Neon; see host docs. |
LASTFM_API_KEY |
Yes for live sync | Last.fm API key. |
LASTFM_USER |
Yes for live sync | Your Last.fm username. |
TIMEZONE |
Recommended | IANA name (e.g. America/New_York) for hour-of-day / day-of-week / daily charts. Many hosts use UTC; without this, “busiest hour” follows UTC, not your local time. |
AUTH_KEY |
Optional | If set, /me requires auth (see below). |
git clone https://github.com/olivertransf/Soundfolio.git
cd Soundfolio
npm installcp .env.example .envFill in DATABASE_URL, LASTFM_API_KEY, and LASTFM_USER.
npm run db:push
npm run db:generatenpm run db:seed-demonpm run dev- Request Extended streaming history from Spotify account privacy (delivery can take days).
- Upload the ZIP in Import.
- Connect Spotify → Last.fm in the Spotify app (Settings → Social) so new plays scrobble.
- Use Sync from Last.fm on Import (the app also triggers a background sync on load).
Does it run automatically? Partly. On each full browser load (hard refresh or opening the site in a new tab), components/sync-on-load.tsx fires one batch each of:
- Last.fm sync (
POST /api/sync-lastfm) - Album art backfill (
POST /api/backfill-art) - Artist image backfill (
POST /api/backfill-artists)
You usually still need to run backfill more than once if you have many tracks or artists missing images:
| Route | Why |
|---|---|
| In-app limits | Each API call processes a fixed batch (e.g. dozens of album groups / artists per request). A full library is not filled in a single run. |
| Client navigation | Clicking around with Next.js <Link> does not remount the layout, so SyncOnLoad does not run again until you refresh the page or open the app again. |
| Manual | Use Import → Backfill buttons anytime; they run the same endpoints as the background job, with loading feedback and response details. |
| CLI | npm run backfill-art / backfill-artists / backfill-all run larger batches from your machine (good for finishing a backlog). |
Summary: backfill auto-starts a little on each full load, but does not guarantee a complete fill in one go—repeat refresh, use the Import page buttons, or run the scripts until remaining is 0 in the response.
- Unset:
/meis open (fine for trusted local use). - Set: use
?key=once or the auth page so the cookie is set; mirror the value in production env.
- Connect the repo; build uses
netlify.toml(prisma generate+next build). - Set env:
DATABASE_URL,LASTFM_API_KEY,LASTFM_USER, and optionalDIRECT_URL,AUTH_KEY,TIMEZONE(recommended so hour-of-day charts match your locale). - First deploy / schema change: from any machine with network access to the DB, run
npx prisma db push(or your migration workflow) against the sameDATABASE_URLNetlify uses, so tables includeisDemo. - Optional
/demo: runnpm run db:seed-demoonce withDATABASE_URLset (e.g. locally or CI), or the demo UI stays empty until you seed.
The build does not run db:push or db:seed-demo automatically—apply schema and seed explicitly.
Backfill uses no Spotify API: album art uses iTunes, Last.fm, and Cover Art Archive; artist images use Discogs, Deezer, and Last.fm. See Backfill: album and artist images for automatic vs manual runs and when to run scripts.
| Command | Description |
|---|---|
npm run dev |
Dev server (port 3000). |
npm run build / start |
Production build / run. |
npm run db:push / db:migrate / db:generate / db:studio |
Prisma. |
npm run backfill-* |
Art / artists backfill CLI. |
npm run db:seed-demo |
Inserts synthetic demo streams into Postgres (isDemo: true). |
npm run remove-demo |
Deletes bundled /demo code and reverts nav/config (optional). |
- Last.fm sync does nothing — Set
LASTFM_API_KEYandLASTFM_USERin.envand restart the dev server. Until then, the API responds withskipped: true(not an error) so background refresh requests stay quiet. - DB SSL — Use
?sslmode=require(or host equivalent) inDATABASE_URL. - Empty charts — Import the ZIP and sync Last.fm so rows exist.
- Empty
/demoor “run db:seed-demo” — Runnpm run db:pushthennpm run db:seed-demo(needsDATABASE_URL). Demo rows are separate from your library (isDemo: true). - Wrong “busiest hour” / time-of-day — Set
TIMEZONEto your real timezone (IANA). Hosted Node often runs in UTC; hour buckets useTIMEZONE(or the server default).


