CI / check (push) Canceled after 0s
- License: AGPL-3.0-or-later (LICENSE, every package.json, README). - MUSICSHARE_FOSS=1 builds leave out the only closed-source code, Google's ML Kit / Play Services barcode scanner bundled by expo-camera; the scan screen then decodes small camera snapshots with jsQR instead. - Expo modules build from source rather than from precompiled AARs, as F-Droid requires (that's also what makes the scanner switch take effect). Verified: a FOSS build contains no ML Kit or Play Services classes. - Release builds default to the production server, and come out unsigned without our upload key (F-Droid signs with its own). versionCode 1. - Store listing in fastlane/metadata/android, a draft fdroiddata recipe in fdroid/, and docs/FDROID.md with the submission steps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
162 lines
12 KiB
Markdown
162 lines
12 KiB
Markdown
# 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:<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/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
|
||
|
||
## F-Droid
|
||
|
||
The Android app builds with no closed-source code when `MUSICSHARE_FOSS=1` is set. See
|
||
[docs/FDROID.md](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](LICENSE).
|
||
If you run a modified version as a service for others, you must make your changes available to them.
|