chrisandClaude Opus 5.5 3229693b63
Android / build (push) Failing after 8m49s
Version 1.1.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:41:17 -04:00
2026-10-15 10:41:17 -04:00
2026-10-06 15:26:59 -04:00
2026-10-03 16:02:38 -04:00
2026-10-03 11:28:17 -04:00
2026-10-06 15:26:59 -04:00
2026-10-15 10:41:17 -04:00
2026-10-06 15:26:59 -04:00
2026-10-06 15:26:59 -04:00

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.

  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.

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_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

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 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.

S
Description
No description provided
Readme AGPL-3.0
4.3 MiB
v1.5.0
Latest
2026-10-26 11:13:08 -04:00
Languages
TypeScript 89.6%
Kotlin 3.9%
CSS 3.8%
JavaScript 0.9%
Shell 0.6%
Other 1.1%