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.
276 lines
13 KiB
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.
|
|
|