# 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. ```bash 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 ```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. - Native tabs and some native modules need a **development build** for full fidelity: `npx expo run:ios` / `npx expo run:android` locally, or `npx eas-cli@latest build --profile development`. - **Spotify/Plex sign-in from a phone:** the OAuth callback goes to `API_PUBLIC_URL`, which defaults to `127.0.0.1` — that's the phone itself. Expose the API with a tunnel (`cloudflared tunnel --url http://localhost:4000`) and set `API_PUBLIC_URL` to the https URL (and add it as a redirect URI in the Spotify dashboard). - Bundle IDs are `tech.chrisedwards.musicshare` (in `apps/mobile/app.json`); change before your first store build, they're permanent after. 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 | ✅ | **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 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