You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
Music/docs/superpowers/specs/2026-05-26-music-streaming-...

276 lines
13 KiB

# Music Streaming — Design Spec
## Overview
Add internet-based music streaming to the Music app. A **host** serves its MP3 library as HLS streams over HTTPS. A **client** downloads the host's database for local browsing and plays audio by streaming from the host. Audio plays on the client, not the host.
This is distinct from the existing remote-mode design (LAN, audio on host). Here the client is an independent player that happens to source its audio and library from a remote host.
## Architecture
```
┌──────────────────────────┐ ┌──────────────────────────┐
│ HOST (Mac) │ │ CLIENT (Mac/iOS) │
│ │ │ │
│ Existing App │ │ Existing App │
│ ┌────────────────────┐ │ │ ┌────────────────────┐ │
│ │ DatabaseService │──┼── GET /db ──► │ Local DB copy │ │
│ │ (source of truth) │ │ │ │ (read-only browse) │ │
│ └────────────────────┘ │ │ └────────────────────┘ │
│ ┌────────────────────┐ │ │ ┌────────────────────┐ │
│ │ StreamingServer │ │◄── HLS ─────│ │ AVPlayer │ │
│ │ - Hummingbird HTTP │ │ requests │ │ (buffered playback)│ │
│ │ - HLS segmenter │ │ │ └────────────────────┘ │
│ │ - WebSocket │ │ │ ┌────────────────────┐ │
│ └────────────────────┘ │ │ │ WebSocket client │ │
│ ┌────────────────────┐ │◄── cmds ────│ │ (RemoteCommand / │ │
│ │ Cloudflare Tunnel │ │── events ──►│ │ HostEvent) │ │
│ │ (cloudflared) │ │ │ └────────────────────┘ │
│ └────────────────────┘ │ │ │
└──────────────────────────┘ └──────────────────────────┘
https://music.yourdomain.com
(Cloudflare edge)
```
### Key Difference from Remote Mode
In remote mode, the client is a remote control — audio plays on the host. In streaming mode, the client is an independent player — it streams audio from the host and plays it locally. The host doesn't play anything when serving a streaming client.
## MusicShared Swift Package
A local Swift package inside the repo, holding code shared between host and client (and later, an iOS target).
Contents:
- **`RemoteProtocol.swift`** — moved from `Music/Remote/`. Contains `RemoteCommand`, `HostEvent`, `PlaybackStatePayload`, `HandshakeMessage`, `RemoteProtocolVersion`.
- **`HLSManifestGenerator.swift`** — pure function: given track duration and segment size, produces `.m3u8` playlist text. No I/O.
- **`APIModels.swift`** — shared DTOs: `AuthResponse` (host info, protocol version), `DBMetadata` (version, checksum for conditional re-download).
- **`Routes.swift`** — route path constants so host and client stay in sync.
- **`StreamingConstants.swift`** — segment duration (6s), default port (8420), protocol version.
## HTTP Endpoints
All endpoints require the `Authorization: Bearer <api-key>` header. Served by Hummingbird on the host.
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/auth` | Validate API key, return host name + protocol version |
| `GET` | `/db` | Download the full SQLite database file |
| `GET` | `/tracks/:id/stream.m3u8` | HLS manifest for a track |
| `GET` | `/tracks/:id/segments/:index.mp3` | Individual MP3 audio segment |
| `GET` | `/ws` | WebSocket upgrade for real-time command/event channel |
No REST API for browsing — the client downloads the full database and queries it locally using existing `DatabaseService` code.
## HLS Streaming
### On-the-Fly Segmentation
When a client requests a track's manifest:
1. Look up the track's file path in the database.
2. Read MP3 duration from file metadata (cached after first read).
3. Generate a `.m3u8` playlist with N segments of 6 seconds each.
When a client requests a segment:
1. Use `AVAssetReader` with a time range (`CMTimeRange`) for the requested segment (e.g., segment 2 = 12s–18s).
2. `AVAssetReader` handles frame-boundary alignment and VBR files correctly.
3. Return the extracted audio bytes.
This avoids raw byte slicing, which breaks on VBR files and frame-boundary misalignment.
### Manifest Format
```
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:6.0,
segments/0.mp3
#EXTINF:6.0,
segments/1.mp3
#EXTINF:4.2,
segments/2.mp3
#EXT-X-ENDLIST
```
### Design Decisions
- **MP3 byte-range slicing** instead of transcoding to AAC. Avoids CPU overhead; `AVPlayer` handles MP3 segments without issues.
- **6-second segments**: HLS standard. Short enough for responsive seeking, long enough to avoid excessive HTTP requests for a single listener.
- **No adaptive bitrate**: the source files are fixed-bitrate MP3s. No need for multiple quality renditions.
## Cloudflare Tunnel
The host uses `cloudflared` to expose its local Hummingbird server to the internet.
### Quick Tunnel (Development)
```
cloudflared tunnel --url http://localhost:8420
```
- Zero config, no account required.
- Generates a random `https://xxx-yyy-zzz.trycloudflare.com` URL.
- URL changes on every restart — must be copied to the client each time.
### Named Tunnel (Recommended for Daily Use)
One-time setup with a Cloudflare account and domain:
```
cloudflared tunnel create music
cloudflared tunnel route dns music music.yourdomain.com
```
Then the app launches:
```
cloudflared tunnel run --url http://localhost:8420 music
```
- Stable URL: `https://music.yourdomain.com`.
- Configure the client once, never touch it again.
### App Integration
- The host manages the `cloudflared` process as a child process (`Process` in Swift).
- On host start: launch `cloudflared`, parse the tunnel URL from stdout.
- On host stop: terminate the `cloudflared` process.
- The host UI displays the current tunnel URL for the user to share with the client.
- The app supports both modes: a toggle or setting to choose quick vs named tunnel.
### Prerequisite
`cloudflared` must be installed separately (`brew install cloudflared`). The app checks for its presence on host startup and shows a clear error with install instructions if missing.
## Authentication
Static API key for personal use.
- The host generates a random API key on first setup (or the user sets one manually).
- The key is stored in the host's Keychain.
- The client stores the host URL + API key in its Keychain.
- Every HTTP request and WebSocket upgrade includes `Authorization: Bearer <api-key>`.
- Invalid keys receive HTTP 401. No retry, no session tokens, no expiry — a static secret over HTTPS is sufficient for single-user.
## Client-Side Playback
### Connection Flow
1. User enters host URL + API key in the connection settings (one-time).
2. Client calls `GET /auth` to validate credentials and check protocol version.
3. Client calls `GET /db` to download the SQLite database, saved to `Application Support/Music/streaming_db.sqlite`.
4. Client opens the DB with `DatabaseService` in read-only mode.
5. Client establishes WebSocket connection to `/ws`.
6. App transitions to streaming mode — existing views reload from the downloaded DB.
### Playback
When the user picks a track:
1. Client constructs the HLS URL: `https://<host>/tracks/<id>/stream.m3u8`.
2. Creates an `AVPlayer` with an `AVURLAsset`, injecting the API key via a custom `AVAssetResourceLoaderDelegate` or URL request headers.
3. `AVPlayer` fetches the manifest, then segments on demand. Buffering, seeking, and playback are handled natively.
4. Client sends `RemoteCommand` over WebSocket to keep the host aware of what's playing (for state sync if multiple clients in the future).
### AudioService Abstraction
The existing `AudioService` plays local files. For streaming, a parallel `StreamingAudioService` wraps `AVPlayer` with HLS URLs. Both conform to a shared protocol so `PlayerViewModel` works with either.
### Database Refresh
Same as remote mode: send `RemoteCommand.refreshDB` over WebSocket → host signals `HostEvent.dbReady` → client re-downloads the DB and reloads views.
## WebSocket Channel
Reuses the existing `RemoteCommand` / `HostEvent` protocol (JSON over WebSocket).
### Client → Host
| Command | Purpose |
|---------|---------|
| `play(trackId, queueIds)` | Inform host what the client is playing |
| `pause` | Client paused |
| `resume` | Client resumed |
| `next` / `previous` | Client changed track |
| `seek(position)` | Client seeked |
| `setVolume(level)` | Client volume changed |
| `toggleShuffle` | Client toggled shuffle |
| `refreshDB` | Request fresh database |
In streaming mode, these commands are informational (the client controls its own playback). They keep the host aware of client state for logging and potential future multi-client coordination.
### Host → Client
| Event | Purpose |
|-------|---------|
| `dbReady` | New database available for download |
| `error(message)` | Server-side error (track file missing, etc.) |
`playbackState` events are less critical in streaming mode since the client drives its own playback, but can be used for sync verification.
### Handshake & Keep-Alive
- On WebSocket connect: exchange `HandshakeMessage` with protocol version and app version.
- Ping/pong every 5 seconds. Connection declared lost after 3 missed pings (15s).
## UI Changes
### Host Mode
- **"Start Streaming Server"** menu toggle — starts Hummingbird + `cloudflared`.
- Status indicator: "Streaming server running · `https://music.yourdomain.com`".
- Settings panel: API key display/regenerate, tunnel mode (quick/named), named tunnel config.
### Client Mode
- **Connection settings**: host URL + API key fields, "Connect" / "Disconnect" button.
- Status indicator: "Connected to [host]" or "Disconnected".
- "Refresh Library" action to re-download the database.
- All existing views (HomeView, TrackTableView, playlists, search, player controls) work unchanged against the local DB copy.
- Playlist creation/editing disabled (read-only snapshot).
### Mode Selection
A setting to choose the app's role: **Local** (default, current behavior), **Host** (serves library), or **Client** (streams from host). Persisted in UserDefaults.
## Testing
### Unit Tests
- `HLSManifestGenerator`: correct `.m3u8` output for various track durations, edge cases (very short tracks, exact multiples of segment duration).
- `RemoteCommand` / `HostEvent` Codable round-trip (already partially covered in `RemoteProtocolTests.swift`).
- API key validation logic.
- Segment extraction: `AVAssetReader` produces valid audio for each segment time range, including edge cases (VBR files, last segment shorter than 6s).
### Integration Tests
- Loopback streaming: start Hummingbird server in-process, request manifest + segments over localhost, verify valid HLS output.
- Database download: verify downloaded DB matches source schema and row counts.
- Auth rejection: requests without or with wrong API key receive 401.
- WebSocket handshake: version mismatch is caught and reported.
### Manual Test Scenarios
- Happy path: start host → connect client → browse library → play track → audio streams and plays on client.
- Seek mid-track → playback resumes from correct position.
- Network interruption → client buffers, resumes when connection returns.
- Kill host mid-playback → client shows error cleanly.
- Add tracks on host → client refreshes DB → new tracks appear.
- Wrong API key → client shows auth error.
- `cloudflared` not installed → host shows clear install instructions.
## Scope & Constraints
- **Single client** for v1. No concurrent listener handling.
- **Read-only client**: no playlist or library modifications from the client.
- **MP3 only**: HLS segmentation assumes MP3 source files (matches current library).
- **`cloudflared` required**: not bundled, must be installed separately.
- **No offline mode**: client requires active connection to stream. Downloaded DB enables browsing but not playback without the host.
- **No transcoding**: segments served as raw MP3 byte ranges.
- **Hummingbird dependency**: added via Swift Package Manager for the embedded HTTP server.