# 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) ```bash 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. 1. **DNS:** an `A` record (and `AAAA` if the server has IPv6) for `musicshare.binarygnome.com` pointing at the server. Ports 80 and 443 must reach it. 2. **Config:** `cp deploy/production.env.example deploy/production.env` and fill it in (`openssl rand -base64 32` for the password and encryption key). 3. **Start:** `docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file deploy/production.env up -d --build` 4. **Spotify (optional):** app credentials only enable exact Spotify links; no redirect URI is needed. 5. **Mobile builds:** set `EXPO_PUBLIC_API_URL=https://musicshare.binarygnome.com/api` and `EXPO_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. ```bash 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 ```bash 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_URL` for 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) or `npx eas-cli@latest build --profile development`. In Expo Go the reader is simply hidden. - `android/` and `ios/` are generated by `expo prebuild` and gitignored; configure native settings in `app.json` and `modules/`. - **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` (in `apps/mobile/app.json`); change before your first store build, they're permanent after. ### Tests ```bash 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.ts` next to the code): matching, private-address blocking, crypto, plan logic, and provider response parsing with `fetch` stubbed. - **Integration tests** (`apps/api/test/`): drive the real Fastify app with `app.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_test` database 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: `routeFetch` stubs 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: 1. Deezer (free, keyless) → ISRC, artwork, Deezer link 2. Spotify search by `isrc:` → exact match 3. Apple Music via iTunes Search + fuzzy matching (upgrade to MusicKit `filter[isrc]` once there's an Apple developer token) 4. 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:` 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/sharing` pauses 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-setup` screen) 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 from `maxPinnedFriends` in the plan. - **Plans:** `users.plan` plus `PLAN_ENTITLEMENTS` in `packages/shared/src/plans.ts` are the single place feature gates live. The API enforces them (e.g. connection limits return `402`), and `/me` returns 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 1. ~~MVP: accounts, follows, Spotify/Last.fm/ListenBrainz, live feed, featured song, cross-service links~~ 2. ~~Plex + Jellyfin~~; ~~Expo app (iOS/Android)~~; ~~pinned friends~~; push notifications; Sign in with Apple/Google 3. Android media-session reader; Apple Music via MusicKit; RevenueCat billing 4. Listening history, reactions/comments on tracks, shareable profile cards (server-rendered OG images) 5. Ops: deploy API/worker containers (Fly.io, Railway or ECS), managed Postgres/Redis, Sentry, metrics on poll latency and per-provider error rates