Files
musicshare/README.md
T
chrisandClaude Opus 5.5 fe503307df Add unit and integration test suites
- Shared: link building, feed ordering, input schemas, API client
- API unit: track matching, private-address blocking, token crypto,
  plan expiry, provider response parsing
- API integration against Postgres and Redis: auth, follows and pins,
  now-playing state, webhooks, OAuth return URLs, realtime, rate limits
- Tests use a dedicated *_test database and Redis DB, and refuse to wipe
  anything else; fetch is stubbed so they never hit the network
- Pin Node to 22.13+ (.nvmrc), which Vite's native bindings require

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
2026-10-03 11:28:17 -04:00

125 lines
8.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
### 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:<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 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