# 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 ` 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 `. - 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:///tracks//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.