A Billboard-style music chart app that pools listening data from Last.FM and ListenBrainz accounts across a group of users. Think of it as a living "what is our dorm/friend group listening to?" leaderboard, rendered in a terminal aesthetic.
| Layer | Technology |
|---|---|
| Frontend | Angular 21+ (standalone components, signals) |
| Backend | Node.js + TypeScript + Express |
| Database | PostgreSQL via Prisma ORM |
| UI Library | @webtui/css + Catppuccin theme |
| Auth | JWT + bcrypt |
| External APIs | Last.FM REST API, ListenBrainz API v1 |
| Package Manager | pnpm (workspace monorepo) |
- Node 20+
- pnpm 9+ —
npm install -g pnpm - Docker — for running PostgreSQL locally
- Last.FM API key — create one at https://www.last.fm/api/account/create
docker compose up -dcp server/.env.example server/.envOpen server/.env and fill in your values:
LASTFM_API_KEY=your_key_here
LASTFM_API_SECRET=your_secret_here
JWT_SECRET=a-long-random-string-at-least-32-charspnpm installpnpm --filter server db:migrateInstead of running the client and server in separate terminals, you can start both simultaneously from the root:
pnpm run devThis will start:
- Backend: http://localhost:3000 (auto-reloads on save)
- Frontend: http://localhost:4200 (Angular dev server with proxy)
- Register/log in at
/register - Go to
/dashboardand click Link Last.FM - Authorize on Last.FM's site — you'll be redirected back automatically
- The app immediately starts an initial scrobble sync in the background
- Copy your user token from https://listenbrainz.org/profile/
- Go to
/dashboard, paste the token, and click Link ListenBrainz
Charts are generated from the aggregated scrobble history of all registered users:
- Weekly — plays in the last 7 days
- Monthly — plays in the last 30 days
- All Time — all plays ever synced
Each entry tracks rank, movement (↑ / ↓ / NEW / —), peak rank, and weeks on chart — just like a real Billboard chart.
Charts are cached for 1 hour and regenerated daily by a background cron job. You can also force a regeneration from the chart view.
# Install all workspace deps
pnpm install
# Backend watch mode
pnpm --filter server dev
# Frontend dev server
pnpm --filter client start
# Open Prisma Studio (visual DB browser)
pnpm --filter server dlx prisma studio
# Re-run after a schema change
pnpm --filter server db:migrate
# Reset the database (⚠️ destroys all data)
pnpm --filter server db:resetdorm-charts/
├── client/ # Angular 21 frontend
│ └── src/app/
│ ├── core/ # Auth service, API client, guards, interceptors
│ ├── features/ # Page-level components (charts, dashboard, auth, profile)
│ └── shared/ # Nav and other reusable components
│
├── server/ # Express + TypeScript backend
│ └── src/
│ ├── config/ # Env validation (zod)
│ ├── db/ # Prisma client singleton
│ ├── jobs/ # Cron scheduler
│ ├── lib/ # Shared axios instance with retry logic
│ ├── middleware/ # JWT auth, error handler
│ └── modules/
│ ├── auth/ # Register, login, /me
│ ├── charts/ # Chart generation engine + REST endpoints
│ ├── sync/ # Last.FM & ListenBrainz sync services
│ └── users/ # Public profiles, linked account management
│
├── shared/ # TypeScript types/DTOs shared between client & server
└── docker-compose.yml # PostgreSQL containerThis project is a monorepo managed with pnpm workspaces. It consists of three primary packages:
client: An Angular 21+ web application. It uses Signals for state management and Standalone Components by default.server: A Node.js / Express backend using Prisma ORM for database access.shared: A lightweight package containing common TypeScript interfaces and DTOs used by both the frontend and backend. This ensures type safety across the entire stack.
The shared package is organized by domain to improve maintainability:
auth.types.ts: Authentication and period definitions.user.types.ts: User profiles and linked account data.track.types.ts: Music track metadata.chart.types.ts: Chart result and history structures.sync.types.ts: Background synchronization status.invite.types.ts: Invitation management types.
All endpoints are prefixed with /api. Authenticated routes require Authorization: Bearer <token>.
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/auth/register |
— | Create account; returns JWT + user |
POST |
/auth/login |
— | Sign in; returns JWT + user |
GET |
/auth/me |
✓ | Get current user with linked accounts |
| Method | Path | Description |
|---|---|---|
GET |
/accounts/lastfm/auth |
Get Last.FM OAuth redirect URL |
POST |
/accounts/lastfm |
Exchange OAuth token for session key and link account |
DELETE |
/accounts/lastfm |
Unlink Last.FM |
POST |
/accounts/listenbrainz |
Validate and link a ListenBrainz token |
DELETE |
/accounts/listenbrainz |
Unlink ListenBrainz |
POST |
/accounts/:id/sync |
Manually trigger a sync for a linked account |
| Method | Path | Description |
|---|---|---|
GET |
/charts?period=WEEKLY |
Fetch current chart (WEEKLY | MONTHLY | ALLTIME) |
POST |
/charts/generate?period=WEEKLY |
Force-regenerate chart |
GET |
/charts/history/:trackId |
Chart history for a specific track |
| Method | Path | Description |
|---|---|---|
GET |
/users/:username |
Public profile with top 10 all-time tracks |
PATCH |
/users/me |
Update username |
# Database
DATABASE_URL=postgresql://dormcharts:dormcharts@localhost:5432/dormcharts
# Server
PORT=3000
# JWT
JWT_SECRET=change-me-to-a-long-random-string
JWT_EXPIRES_IN=7d
# Last.FM (required — https://www.last.fm/api/account/create)
LASTFM_API_KEY=
LASTFM_API_SECRET=
LASTFM_CALLBACK_URL=http://localhost:4200/auth/lastfm/callback
# ListenBrainz
LISTENBRAINZ_BASE_URL=https://api.listenbrainz.org
# Background jobs
SYNC_INTERVAL_MINUTES=60 # How often to auto-sync accounts
CHART_REGEN_CRON=0 3 * * * # When to regenerate charts (default: 3 AM daily)
CLOUDFLARE_R2_ACCOUNT_ID=
CLOUDFLARE_R2_ACCESS_KEY_ID=
CLOUDFLARE_R2_SECRET_ACCESS_KEY=
CLOUDFLARE_R2_BUCKET_NAME=
CLOUDFLARE_R2_PUBLIC_URL="Invalid environment variables" on server start:
- Make sure
server/.envexists and all required keys are filled in. Copy fromserver/.env.example.
Last.FM OAuth callback fails:
- Confirm
LASTFM_CALLBACK_URLin.envexactly matches the callback URL registered in your Last.FM API account settings.
Charts show no data:
- Scrobble data must be synced first. Link an account on
/dashboard, wait for the initial sync, then use Generate Chart Now or wait for the cron to run.
ListenBrainz token invalid:
- Tokens are account-specific. Get yours from https://listenbrainz.org/profile/ while logged in.