- Anyone who can see a play can read its comments; the player and the people they follow can write. The author, the player and admins can delete; comments can be reported (admins see the text). - Notifications: "commented on" for the player, "replied on" for earlier commenters, each with its own setting. Reaction and comment notifications now open the play. - A 💬 chip with the count next to reactions on the feed and recent plays, opening the thread on the app (/play/[id]) and the website. Threads refresh every few seconds while open. - Blocking removes comments between the two people. Plays with comments survive history trimming. - Privacy page updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Y2iUsKEWDuRGjK5FPmcUP
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 with Docker (everything)
cp apps/api/.env.example apps/api/.env # first time: set TOKEN_ENCRYPTION_KEY (openssl rand -base64 32)
docker compose up -d --build # Postgres, Redis, migrations, API, worker, web
Open http://localhost:3737. Logs: docker compose logs -f api worker. Stop: docker compose down (data is kept in volumes).
Migrations run automatically before the API and worker start. A Jellyfin/Plex server on your machine is reachable from the containers as host.docker.internal.
Deploying (musicshare.binarygnome.com)
Everything is served from one origin: the web app at /, the API at /api (Caddy strips the prefix), including the /api/ws websocket. Caddy obtains and renews HTTPS certificates automatically.
- DNS: an
Arecord (andAAAAif the server has IPv6) formusicshare.binarygnome.compointing at the server. Ports 80 and 443 must reach it. - Config:
cp deploy/production.env.example deploy/production.envand fill it in (openssl rand -base64 32for the password and encryption key). - Start:
docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file deploy/production.env up -d --build - Spotify (optional): app credentials only enable exact Spotify links; no redirect URI is needed.
- Mobile builds: set
EXPO_PUBLIC_API_URL=https://musicshare.binarygnome.com/apiandEXPO_PUBLIC_WEB_URL=https://musicshare.binarygnome.com.
In production only Caddy is exposed; Postgres, Redis, the API and the web app stay on the internal Docker network. A backup service writes a gzipped pg_dump to ./backups nightly and keeps 14 days. Copy that folder off the server regularly. The website is installable as a PWA once it's on HTTPS.
Running for development (hot reload)
Requires Node 22.13+ and Docker.
npm install
docker compose up -d postgres redis # 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 |
Optional: exact "Open in Spotify" links via catalog search (app-level credentials; no user sign-in). Without it, Spotify links come from song.link or a search. |
LASTFM_API_KEY |
Last.fm connections (covers YouTube Music, Tidal, Deezer, SoundCloud… via scrobbling) |
LASTFM_SHARED_SECRET |
One-tap "Sign in with Last.fm" (instead of typing a username). Also the main route for Spotify: users link Spotify to Last.fm on Last.fm's site. |
APPLE_TEAM_ID, APPLE_MUSICKIT_KEY_ID, APPLE_MUSICKIT_PRIVATE_KEY |
Apple Music. Create a key with MusicKit enabled under Apple Developer → Keys; paste the .p8 contents with newlines as \n. |
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. - The app includes custom native code (the Android now-playing reader), so it needs a development build, not Expo Go:
npm run android -w @musicshare/mobile(needs the Android SDK and JDK 17) ornpx eas-cli@latest build --profile development. In Expo Go the reader is simply hidden. android/andios/are generated byexpo prebuildand gitignored; configure native settings inapp.jsonandmodules/.- Plex/Last.fm sign-in from a phone: the sign-in callback goes to
API_PUBLIC_URL, so set it to an address the phone can reach (your LAN IP, a tunnel, or the production domain). - 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 | ✅ | No direct sign-in (Spotify caps new apps at 5 accounts). Users link Spotify to Last.fm or ListenBrainz (Spotify's own integrations) and we read it from there; on Android the phone reader shares Spotify automatically. Guided in onboarding and Settings. |
| 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 | ✅ (iOS) | One system prompt in the iPhone app. The server follows recently played (no timestamps, so a new top song is shown as playing for its length; connecting starts from a baseline). While the app is open it mirrors the Music app live. Native code: apps/mobile/modules/apple-music. |
| YouTube Music | via Last.fm | No official history API |
| Android (any music app) | ✅ | One toggle in the app. modules/now-playing-reader listens to system media sessions (via notification access, never reading notification contents), filters to known music apps, and reports changes plus a 4-minute heartbeat through WorkManager with a per-phone revocable token. Testing with apps outside the allowlist: EXPO_PUBLIC_EXTRA_MEDIA_APPS=com.android.chrome (dev only). |
SaaS readiness
- Pause sharing:
PUT /me/sharingpauses for 1h, 8h or indefinitely. While paused, connections aren't polled, phone reports are dropped, and nothing is added to history. - Google Play: notification access needs the in-app disclosure (
share-setupscreen) and a matching Data safety entry (listening activity, shared with friends). - 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
F-Droid
The Android app builds with no closed-source code when MUSICSHARE_FOSS=1 is set. See
docs/FDROID.md for the build, the store listing in fastlane/, and how to submit.
License
musicshare is free software under the GNU Affero General Public License v3.0 or later. If you run a modified version as a service for others, you must make your changes available to them.