Servers advertise many addresses (public, relay, LAN, Docker bridges). Use the last one that worked and race the rest only if it fails. Friends' servers that keep failing back off exponentially instead of slowing every poll. Errors now name each server and the underlying cause, e.g. "blackcoffee: ECONNREFUSED", instead of undici's generic "fetch failed". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
musicshare
See what your friends are listening to, live, across Spotify, Apple Music, YouTube Music, Plex, Jellyfin and anything that scrobbles. Tap a song to open it in your streaming app.
Layout
apps/
api/ Fastify API + background worker (Node, TypeScript)
web/ Next.js web app
mobile/ Expo (React Native) app for iOS + Android
packages/
shared/ Types, zod schemas, plan entitlements, streaming-link helpers
Running locally
Requires Node 22.13+ and Docker.
npm install
npm run infra:up # Postgres on :5447, Redis on :6380
cp apps/api/.env.example apps/api/.env # then set TOKEN_ENCRYPTION_KEY (openssl rand -base64 32)
cp apps/web/.env.example apps/web/.env.local
npm run db:migrate
npm run dev # API :4000, worker, web :3737
Optional keys in apps/api/.env:
| Key | Enables |
|---|---|
SPOTIFY_CLIENT_ID/SECRET |
Spotify connections + exact Spotify links via ISRC. Add redirect URI http://127.0.0.1:4000/connections/spotify/callback in the Spotify dashboard (Spotify rejects localhost). |
LASTFM_API_KEY |
Last.fm connections (covers YouTube Music, Tidal, Deezer, SoundCloud… via scrobbling) |
ODESLI_API_KEY |
Direct YouTube Music + TIDAL links via song.link. Without it, those fall back to search links. |
PLEX_CLIENT_ID |
Identifies the app in Plex's sign-in flow. Pick a unique value before launch and never change it. |
ALLOW_PRIVATE_SERVER_URLS |
true lets users connect LAN Jellyfin/Plex servers. Fine for local dev and self-hosting; must be false in a hosted deployment. |
ListenBrainz, Plex, Jellyfin, Deezer and Apple Music (iTunes Search) need no keys.
Webhooks (Plex/Jellyfin) need the API reachable from wherever the media server runs. For local dev with a remote server, use a tunnel (e.g. cloudflared tunnel --url http://localhost:4000) and set API_PUBLIC_URL to it.
Mobile app
npm run mobile # starts Metro; scan the QR code with a dev build or Expo Go
- The app talks to the API on the same host as Metro (your LAN IP, port 4000), so phone and computer must be on the same network. Set
EXPO_PUBLIC_API_URLfor other environments. - Native tabs and some native modules need a development build for full fidelity:
npx expo run:ios/npx expo run:androidlocally, ornpx eas-cli@latest build --profile development. - Spotify/Plex sign-in from a phone: the OAuth callback goes to
API_PUBLIC_URL, which defaults to127.0.0.1— that's the phone itself. Expose the API with a tunnel (cloudflared tunnel --url http://localhost:4000) and setAPI_PUBLIC_URLto the https URL (and add it as a redirect URI in the Spotify dashboard). - Bundle IDs are
tech.chrisedwards.musicshare(inapps/mobile/app.json); change before your first store build, they're permanent after.
Tests
npm test # all workspaces via Turborepo (needs `npm run infra:up`)
npm run test:unit -w @musicshare/api # API unit tests only, no database needed
- Unit tests (
*.test.tsnext to the code): matching, private-address blocking, crypto, plan logic, and provider response parsing withfetchstubbed. - Integration tests (
apps/api/test/): drive the real Fastify app withapp.inject()against Postgres and Redis, covering auth, follows and pins, now-playing state, webhooks, OAuth return URLs, realtime WebSockets and rate limits. - They use a separate
musicshare_testdatabase and Redis DB 1, created and migrated automatically. Test helpers refuse to wipe anything else, so your dev data is safe. - Tests never touch the network:
routeFetchstubs fail on unexpected requests.
CI (.github/workflows/ci.yml) runs typecheck, lint, tests, a migrations-match-schema check, the web build, and the iOS/Android bundles.
Schema changes: edit apps/api/src/db/schema.ts, then npm run db:generate && npm run db:migrate.
How it works
Now playing. Each connected service is a connection. The worker polls connections on an adaptive schedule held in a Redis sorted set (services/scheduler.ts):
- someone is looking at you (feed or profile open) → every 5–15s depending on plan
- you're playing but no one's watching → 60s
- idle → 180s
This keeps third-party API usage proportional to real engagement rather than to signups. Any number of workers can run: claiming due polls is an atomic Lua script, so no leader election is needed. Push-based sources (Plex/Jellyfin webhooks, the Android app) will write through the same applyPollResult path.
Cross-service links. Tracks are deduped by ISRC → known provider ID → normalized artist|title. A resolver job (services/resolver.ts) fills in links:
- Deezer (free, keyless) → ISRC, artwork, Deezer link
- Spotify search by
isrc:→ exact match - Apple Music via iTunes Search + fuzzy matching (upgrade to MusicKit
filter[isrc]once there's an Apple developer token) - song.link → YouTube Music, TIDAL
Links are https universal links, so on phones they open the native app. Missing links fall back to a search on that service.
Realtime. Workers publish np:<userId> to Redis; every API instance pattern-subscribes and forwards over WebSocket to sockets following that user. API instances are stateless and can scale behind any load balancer.
Webhooks. /webhooks/:provider/:secret (Plex, Jellyfin) ignores the request body and simply pulls that connection's next poll forward. One code path owns state, any Jellyfin webhook template works, and a forged call can at most cause one extra poll.
User-supplied servers. Jellyfin URLs and discovered Plex addresses are checked against private/loopback/link-local ranges (lib/ssrf.ts). The check runs both up front and at connect time, which defeats DNS rebinding. Redirects are refused.
Auth. Session tokens (stored as sha256) are sent as an httpOnly cookie on the web, or as Authorization: Bearer for mobile (the token is returned from /auth/login and /auth/register). Provider OAuth tokens are encrypted with AES-256-GCM.
Service support
| Service | Status | Notes |
|---|---|---|
| Spotify | ✅ | Development mode caps an app at 25 allow-listed users. Since May 2025, extended quota is only granted to registered businesses with an established user base, so plan for this before launch. Last.fm is the fallback for Spotify users beyond the cap. |
| Last.fm | ✅ | Covers almost any service via scrobbling |
| ListenBrainz | ✅ | |
| Plex | ✅ | Plex sign-in (PIN flow), server discovery via plex.tv, polls /status/sessions on each server (remote → relay → LAN). Optional Plex Pass webhook for instant updates. |
| Jellyfin | ✅ | Server URL + login; only the issued access token is stored and it's revoked on disconnect. Optional Webhook-plugin URL for instant updates. |
| Apple Music | ⏳ | No server-side now-playing API. MusicKit gives recently-played only; the iOS app can read the device player while open. |
| YouTube Music | via Last.fm | No official history API |
| Android (any app) | ⏳ | The mobile app reads the system media session (NotificationListenerService) |
SaaS readiness
- Pinned friends: followers can pin friends to the top of their feed (
PUT/DELETE /users/:username/pin); the limit comes frommaxPinnedFriendsin the plan. - Plans:
users.planplusPLAN_ENTITLEMENTSinpackages/shared/src/plans.tsare the single place feature gates live. The API enforces them (e.g. connection limits return402), and/mereturns them so clients can show upsells. - Billing (next): RevenueCat is recommended. It unifies App Store, Play Store and Stripe (web) subscriptions, and a webhook sets
plan/plan_expires_at. App Store and Play rules require in-app purchase for digital features bought in-app. - App Store requirements already covered: in-app account deletion (
DELETE /me), and bearer auth for mobile. Still to do: Sign in with Apple (required if any other social login is offered) and a privacy policy. - Abuse: rate limits are backed by Redis, so they're global across instances.
Roadmap
MVP: accounts, follows, Spotify/Last.fm/ListenBrainz, live feed, featured song, cross-service linksPlex + Jellyfin;Expo app (iOS/Android);pinned friends; push notifications; Sign in with Apple/Google- Android media-session reader; Apple Music via MusicKit; RevenueCat billing
- Listening history, reactions/comments on tracks, shareable profile cards (server-rendered OG images)
- Ops: deploy API/worker containers (Fly.io, Railway or ECS), managed Postgres/Redis, Sentry, metrics on poll latency and per-provider error rates