A Last.fm now-playing overlay that runs as a browser source in OBS, Streamlabs, or XSplit. You lay the overlay out in a drag-and-drop editor, and the editor hands you a URL to paste into your scene. The entire design is serialized into that URL, so there are no accounts and nothing is persisted server-side.
Technically it's a TypeScript monorepo: a SvelteKit editor and widget SPA, a Bun + Hono API, and a local Redis + Postgres dev stack.
- Live app: fast.jamlog.lol
- User docs: the wiki (source in
wiki/)
- How It Works
- Repository Layout
- Architecture
- Local Development
- Environment Variables
- Common Commands
- API Surface
- Deployment
- Operational Notes
- Known Constraints
- Contributing
- License
The editor (/) is a canvas for laying out the widget's pieces: album art, title, artist, album, a progress bar, and an elapsed/remaining time readout. Elements can be dragged, resized, and snapped to each other, and each one carries its own font, colors, and shadow, plus a per-track-change animation. State autosaves to localStorage on every change, so closing the tab doesn't lose work.
The full config is serialized to JSON, base64url-encoded, and packed into the widget URL's hash (/w#<blob>). The widget page (/w) decodes the hash, polls Last.fm for the current track, and renders. Because the config lives entirely in the hash, the URL is the document: copy it into OBS and it renders, no server round-trip for the design itself.
There are two layout engines, and the version flag in the encoded config picks which renderer runs:
version: 1(legacy) is a fixed grid, rendered byWidgetLegacy.svelte.version: 2(current) is free positioning. Every element getsx/y/w/h, a z-index, and optional snap relationships to other elements, rendered byWidgetV2.svelte.
A migrateToV2 step upgrades old grid designs into the equivalent free layout, so existing v1 URLs keep working. The full config shape lives in config.ts.
Last.fm has no push API, so the widget polls user.getRecentTracks and watches the nowplaying flag. Since every widget polls from its viewer's own IP (see below), the cadence is a flat 1 second regardless of playback state, so track changes, pauses, and playback starting all land within about a second (nowplaying.svelte.ts). The only backoff is a hidden tab (5s), which OBS browser sources never trigger because they always report as visible.
Between polls the progress bar ticks locally off the track's reported duration, with pause detection and a resume-position estimate so the bar doesn't freeze or jump.
Last.fm rate-limits at roughly 5 requests per second per IP. If every viewer fetched through the server, they'd all share the server's single IP, and any streamer with a real audience going live would throttle everyone at once.
So public lookups (recent tracks, track info) go straight from each viewer's browser to ws.audioscrobbler.com (lastfm-client.ts), and each viewer spends their own per-IP budget. Album art and color extraction load directly from Last.fm's CDN the same way, since it sends Access-Control-Allow-Origin: *. A transport-level failure (network or CORS) falls back to the server proxy.
Private profiles go browser-direct too, with one extra step. A hidden listening profile requires a signed call, and the signature needs the Last.fm shared secret, which never leaves the server. Last.fm signatures carry no timestamp or nonce, so the server signs the recent-tracks URL once (GET /api/lastfm/sign-recent) and the browser reuses that pre-signed URL for every poll, straight against Last.fm from the viewer's IP (lastfm.ts). The signed proxy routes remain as the fallback if signing or the direct call fails.
.
├── apps/
│ ├── web/ # SvelteKit SPA: editor (/), widget (/w), auth callback (/callback)
│ └── server/ # Bun/Hono API; serves the built SPA, talks to Redis + Postgres
├── scripts/ # cron cleanup helper
├── tests/ # unit tests
├── docker-compose.dev.yml # local Redis + Postgres stack
├── Dockerfile # production image: builds the SPA, starts the Hono server
├── railway.json # Railway deployment config
├── drizzle.config.ts # Drizzle Kit migration config
└── package.json # Bun workspace scripts
A SvelteKit single-page application built with adapter-static (pure SPA, CSR only), Svelte 5 runes, and Tailwind v4.
Responsibilities:
- The drag-and-drop editor, presets, and per-element styling.
- Encoding and decoding the widget config to and from the URL hash.
- Browser-direct Last.fm polling and widget rendering.
- The Last.fm auth callback for private-profile sessions.
Key files:
config.ts: config schema, encode/decode, v1→v2 migrationeditor.svelte.ts: editor statenowplaying.svelte.ts: polling + progresslastfm-client.ts: browser-direct Last.fm clientWidgetLegacy.svelte/WidgetV2.svelte: renderers
A Bun-powered Hono service. In production it serves both the static build and the API on one port; in dev, Vite serves the UI and proxies /api to it. Redis (Bun.redis) is a small cache in front of the signed/proxied Last.fm paths, and Postgres (Drizzle, drizzle-orm/bun-sql) backs the optional usage analytics and contact emails.
Responsibilities:
- One-time signing of private-profile poll URLs, plus the fallback proxy for browser-direct calls.
- Exchanging Last.fm auth tokens for session keys.
- A host-allowlisted album-art image proxy.
- Fire-and-forget visitor logging and contact storage.
- Per-IP rate limiting and visitor-log cleanup.
Key files:
index.ts: routing + static servinglastfm.ts: signing, proxy, session exchangesecurity.ts: rate limiting, image-proxy allowlistschema.ts: Drizzle schema
Prerequisites
- Bun 1.3.x or newer
- Docker (optional, for local Redis + Postgres; both fail open without it)
Quick start
bun install
bun run services:up # Redis + Postgres in Docker (optional)
bun run dev # Vite UI on :5173, Hono API on :8787Then open http://localhost:5173.
Config is read from a repo-root .env (server) and .env.local (frontend VITE_* values). Copy .env.example to get started.
| Variable | Side | Purpose |
|---|---|---|
LFM_API_KEY / LFM_SHARED_SECRET |
server | Last.fm credentials (the secret signs private/proxied calls) |
REDIS_URL |
server | cache + rate-limit store; unset = no cache, no limiting |
DATABASE_URL |
server | Postgres for the visitor log + contacts; unset = off |
CRON_SECRET |
server | bearer token guarding POST /api/cron/cleanup; unset = route disabled |
PORT / LOG_LEVEL |
server | bind port / log verbosity |
VITE_LFM_KEY |
frontend (build) | public Last.fm key for browser-direct calls |
VITE_LFM_CALLBACK |
frontend (build) | Last.fm auth callback URL |
Run these from the repository root.
| Command | Purpose |
|---|---|
bun run dev |
Vite UI + Hono API together (Vite proxies /api) |
bun run build |
Build the SvelteKit SPA |
bun run typecheck |
Typecheck all workspaces |
bun test |
Run unit tests |
bun run services:up / services:down |
Local Redis + Postgres in Docker |
bun run db:generate |
Generate Drizzle migrations from the schema |
bun run db:migrate |
Apply Drizzle migrations |
bun run cron:cleanup |
Run visitor-log housekeeping by hand |
Everything lives under /api. The Last.fm proxy routes mostly exist as the fallback; the common case (public and private alike) polls Last.fm directly and never touches them.
| Endpoint | Purpose |
|---|---|
GET /api/ping |
Liveness; always 200 while the process is up |
GET /api/health |
Reports Redis (and informational Postgres) status; may 503 if Redis is configured but down |
GET /api/lastfm/recent, /trackInfo |
Signed/cached proxy, the fallback when browser-direct calls fail |
GET /api/lastfm/sign-recent |
Signs a private-profile recent-tracks URL once; the browser then polls Last.fm directly with it |
POST /api/lastfm/session |
Exchange a Last.fm auth token for a session key (signed) |
GET /api/proxy-image |
Album-art fetch fallback; host-allowlisted |
POST /api/log/widget |
Fire-and-forget visitor log, one row per visitor (fail-open) |
POST /api/contact |
Store a contact email against a Last.fm username |
POST /api/cron/cleanup |
Visitor-log housekeeping (dedupe + prune); requires CRON_SECRET |
GET /robots.txt, /sitemap.xml |
Crawler files |
The whole thing ships as one Railway service plus the Redis and Postgres plugins. The Dockerfile builds the SPA and starts the Hono server, which serves both.
- Point Railway at the repo and add the Redis and Postgres plugins.
- Set the service variables:
LFM_API_KEY,LFM_SHARED_SECRET,REDIS_URL=${{Redis.REDIS_URL}},DATABASE_URL=${{Postgres.DATABASE_URL}}, plus the build-timeVITE_LFM_KEYandVITE_LFM_CALLBACK=https://<domain>/callback. - Deploy. Drizzle migrations run on the server's first write, so there's no manual migrate step.
After changing schema.ts, run bun run db:generate and commit the generated file under apps/server/drizzle/. To apply migrations by hand, point DATABASE_URL at the target and run bun run db:migrate.
Caching. The proxy cache is deliberately short: recent-tracks responses live for 1 second, track-info for 24 hours.
Rate limiting. A lenient per-IP limit (60 requests / 10s, roughly 360/min) that normal polling never approaches. It only trips on abusive bursts and fails open when Redis is down (security.ts).
Image proxy. Host-allowlisted (Last.fm/Spotify/Apple/etc. CDNs only), so it can't be turned into an open SSRF proxy.
Fail-open services. Redis and Postgres are both optional. If either is unreachable, the widget keeps serving; you lose caching/rate limiting or visitor logging until it's back.
- The design lives entirely in the URL hash. No accounts, no server-side saves. Lose the URL, lose the design (the editor keeps a
localStorageautosave as a safety net). - Last.fm calls go browser-direct by design, public and private alike (private via a one-time server-signed URL). The server proxy is only the fallback.
- Last.fm has no event stream, so "now playing" is polled and runs about a second behind reality.
- Browser-direct calls necessarily expose the Last.fm API key in DevTools (it's a query param on every request, including the pre-signed private URLs). This is how the Last.fm API works for any client-side app: the key is not a credential, the shared secret is, and the secret stays server-side.
Contributions are welcome. See CONTRIBUTING.md for setup, the checks to run before opening a PR, and how contributions are licensed.
Source-available for personal use, including self-hosting and streaming with it. Redistribution, republishing, and commercial redistribution require permission. See LICENSE for the full terms.
Questions or redistribution requests: me@lawsonhart.me.