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 fromMusic/Remote/. ContainsRemoteCommand,HostEvent,PlaybackStatePayload,HandshakeMessage,RemoteProtocolVersion.HLSManifestGenerator.swift— pure function: given track duration and segment size, produces.m3u8playlist 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:
- Look up the track's file path in the database.
- Read MP3 duration from file metadata (cached after first read).
- Generate a
.m3u8playlist with N segments of 6 seconds each.
When a client requests a segment:
- Use
AVAssetReaderwith a time range (CMTimeRange) for the requested segment (e.g., segment 2 = 12s–18s). AVAssetReaderhandles frame-boundary alignment and VBR files correctly.- 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;
AVPlayerhandles 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.comURL. - 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
cloudflaredprocess as a child process (Processin Swift). - On host start: launch
cloudflared, parse the tunnel URL from stdout. - On host stop: terminate the
cloudflaredprocess. - 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
- User enters host URL + API key in the connection settings (one-time).
- Client calls
GET /authto validate credentials and check protocol version. - Client calls
GET /dbto download the SQLite database, saved toApplication Support/Music/streaming_db.sqlite. - Client opens the DB with
DatabaseServicein read-only mode. - Client establishes WebSocket connection to
/ws. - App transitions to streaming mode — existing views reload from the downloaded DB.
Playback
When the user picks a track:
- Client constructs the HLS URL:
https://<host>/tracks/<id>/stream.m3u8. - Creates an
AVPlayerwith anAVURLAsset, injecting the API key via a customAVAssetResourceLoaderDelegateor URL request headers. AVPlayerfetches the manifest, then segments on demand. Buffering, seeking, and playback are handled natively.- Client sends
RemoteCommandover 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
HandshakeMessagewith 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.m3u8output for various track durations, edge cases (very short tracks, exact multiples of segment duration).RemoteCommand/HostEventCodable round-trip (already partially covered inRemoteProtocolTests.swift).- API key validation logic.
- Segment extraction:
AVAssetReaderproduces 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.
cloudflarednot 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).
cloudflaredrequired: 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.