# 06 — Chromecast: Receiver & Sender Design Status: **Normative design contract** for FablePool's Cast integration. Audience: Android app team, web client team, server team. This document specifies how FablePool streams audio to Google Cast devices (Chromecast, Chromecast Audio, Google/Nest speakers, Android TV) from the Android app and the web client, and what the server must provide to make Cast playback work against S3/WebDAV-backed libraries. --- ## 1. Goals & Constraints | # | Requirement | |---|-------------| | G1 | Cast playback of any track in the user's library, including tracks that require transcoding (see `docs/04-caching-and-transcoding.md`). | | G2 | The Cast device fetches media **directly from the FablePool server** — never from S3/WebDAV directly. The server is the only component holding storage credentials (see `docs/03-storage-abstraction.md`, §7 on the "no credential leakage" invariant). | | G3 | Auth on Cast media URLs must not embed long-lived user credentials, because media URLs are visible in Cast device logs and on the local network. | | G4 | Queue semantics on the Cast device must match the in-app queue, including AI auto-play of the next track (see `docs/07-recommendation-engine.md`). | | G5 | Gapless-adjacent behavior: preload the next item so inter-track silence is < 500 ms on stock Chromecast hardware. | | G6 | Work with the **Default Media Receiver** as a fallback, but ship a **custom CAF receiver** as the primary path (needed for G4 auto-play and styled now-playing UI). | Non-goals (v1): multi-room grouping control beyond what Cast firmware does natively; video; casting to AirPlay/DLNA (tracked as a possible later milestone). --- ## 2. Component Overview ```mermaid flowchart LR subgraph LAN["User's network"] A[Android App
Cast Sender
Cast SDK / Media3 CastPlayer] W[Web Client
Cast Sender
CAF Sender JS] C[Chromecast Device
Custom CAF Receiver
HTML5 app] end S[FablePool Server
REST + Subsonic API
Stream endpoints] B[(S3 / WebDAV
backends)] A -- "Cast protocol (mDNS + TLS)" --> C W -- "Cast protocol" --> C C -- "HTTPS GET /api/v1/stream/{trackId}?castToken=…" --> S A -- "HTTPS REST/Subsonic" --> S W -- "HTTPS REST/Subsonic" --> S S -- "ranged GET / presigned GET" --> B ``` Key consequence of G2: **the Cast device must be able to reach the FablePool server over HTTPS with a certificate it trusts.** Chromecast firmware rejects self-signed certificates. Deployment requirement (documented in the ops guide later): the server must be reachable at a hostname with a valid public CA certificate (Let's Encrypt is fine), even for LAN-only deployments (e.g. via a DNS name resolving to a LAN IP — Chromecast accepts that as long as the cert chain validates). --- ## 3. Receiver Application ### 3.1 Choice: Custom CAF Receiver We register a **custom Web Receiver** built on the Cast Application Framework (CAF Receiver SDK v3, `//www.gstatic.com/cast/sdk/libs/caf_receiver/v3/cast_receiver_framework.js`). Rationale: 1. **Auto-play hook (G4):** when the receiver-side queue is about to run dry, the receiver asks the *sender* (not the server) for the next item over a custom namespace. The sender consults the recommendation engine and appends. This keeps recommendation auth on the sender and keeps the receiver stateless. 2. **Token refresh:** Cast media URLs carry short-lived `castToken`s (§5). The receiver intercepts `LOAD` and `MEDIA_STATUS` to refresh expired tokens via a custom-namespace round trip rather than failing playback. 3. **Branding:** album art, progress, artist/album metadata in our styling. A **styled/default receiver fallback** must still work for basic single-track and static-queue playback so that users can cast before the custom receiver is approved in the Cast Developer Console, and in self-hosted setups where the admin does not register their own receiver app ID. The server config exposes: ```yaml cast: receiver_app_id: "ABCD1234" # custom receiver; empty => default receiver CC1AD845 ``` The sender fetches this from `GET /api/v1/system/cast-config` at startup. ### 3.2 Receiver hosting The receiver is a static HTML+JS bundle served by the FablePool server itself at `GET /cast/receiver/` (so self-hosters control it and version skew with the server is impossible). The Cast Developer Console entry for a deployment points at `https:///cast/receiver/`. ### 3.3 Custom namespace ``` urn:x-cast:org.fablepool.cast ``` Message envelope (JSON, both directions): ```json { "type": "", "requestId": 17, "payload": { } } ``` | Type | Direction | Payload | Purpose | |------|-----------|---------|---------| | `NEXT_ITEM_REQUEST` | receiver → sender | `{ "currentItemId": "trk_…", "queueRemaining": 1 }` | Receiver asks for the next auto-play item when ≤ 1 item remains and auto-play is enabled. | | `NEXT_ITEM_RESPONSE` | sender → receiver | `{ "item": \| null }` | `null` means "stop after queue ends" (auto-play off or recommender returned nothing). | | `TOKEN_REFRESH_REQUEST` | receiver → sender | `{ "trackId": "trk_…" }` | Media URL token expired mid-session (e.g. long pause). | | `TOKEN_REFRESH_RESPONSE` | sender → receiver | `{ "trackId": "trk_…", "castToken": "…", "expiresAt": "ISO-8601" }` | Receiver rewrites the queue item's `contentUrl` before (re)loading. | | `SCROBBLE_HINT` | receiver → sender | `{ "trackId": "trk_…", "positionMs": 123456, "event": "started" \| "completed" }` | Receiver-side playback progress so the *sender* posts play-history (§7). Receiver never calls authenticated history APIs itself. | `requestId` correlates request/response; responses echo the request's id. Senders must tolerate unknown `type`s (forward compatibility). ### 3.4 Receiver behavior (normative) - On `LOAD`, validate that `media.contentUrl` host matches the receiver's own origin host (defense against queue injection from other senders on the LAN in shared environments); reject with `LOAD_FAILED` / reason `INVALID_REQUEST` otherwise. - Use CAF queueing (`cast.framework.QueueBase` subclass). When `nextItems().length <= 1` **and** the load request's `customData.autoPlayNext` is `true`, send `NEXT_ITEM_REQUEST`. Timeout 5 s; on timeout, do nothing (queue simply ends). - Preload: set `queueItem.preloadTime = 20` (seconds before end of current item) to satisfy G5. - On HTTP 401 from a media URL, attempt exactly one `TOKEN_REFRESH_REQUEST` round trip, rewrite the URL, retry. Second failure → surface `MEDIA_ERROR`. - Idle timeout: standard CAF 10-minute idle teardown; do not override. --- ## 4. Media Item Contract Every queue item the sender loads (and every item in `NEXT_ITEM_RESPONSE`) uses this shape (CAF `MediaInformation` + `customData`): ```json { "contentUrl": "https://fp.example.com/api/v1/stream/trk_8f2a?castToken=v1.eyJ…&format=raw", "contentType": "audio/flac", "streamType": "BUFFERED", "metadata": { "metadataType": 3, "title": "Song Title", "artist": "Artist Name", "albumName": "Album Name", "images": [ { "url": "https://fp.example.com/api/v1/art/alb_91c0?size=512&castToken=v1.eyJ…" } ] }, "duration": 247.0, "customData": { "trackId": "trk_8f2a", "autoPlayNext": true, "queueSourceId": "pl_44d1", "recommendationSeed": { "windowN": 10 } } } ``` Rules: - `metadataType: 3` = `MUSIC_TRACK` (Cast `MusicTrackMediaMetadata`). - `contentType` must be the **actual delivered** MIME type. If the server will transcode for this device class (§6), the sender requests the stream decision first and sets `contentType` accordingly. - `streamType` is always `BUFFERED` (we never present live streams). - Album art URLs also carry a `castToken` because the receiver fetches them unauthenticated otherwise. --- ## 5. Cast Token (media URL auth) Per G3, normal bearer tokens never appear in media URLs. Instead the sender exchanges its session for a **cast token**: ``` POST /api/v1/cast/token Authorization: Bearer { "trackIds": ["trk_8f2a", "trk_77b1"], "includeArt": true } 200 → { "tokens": { "trk_8f2a": { "castToken": "v1.…", "artToken": "v1.…", "expiresAt": "2025-06-01T12:34:56Z" } } } ``` Properties (full token format in `docs/05-auth-and-security.md` §6, "URL-scoped tokens"; this section binds Cast specifics): - **Scope:** exactly one `trackId` (or one art object), `stream:read` only. - **TTL:** 6 hours (long enough for an album + pauses; short enough to bound replay). Refreshable via the §3.3 custom-namespace flow. - **Stateless verification:** HMAC-signed compact token verified by the server without a DB hit on every byte-range request; contains `{userId, trackId, scope, exp}`. - Tokens are **bound to the user**, so play-history attribution and per-user transcode policy still resolve correctly server-side. - Server emits these URLs with `Cache-Control: private` and they are excluded from access logs' query strings (log scrubbing requirement). Range requests: the `/stream` endpoint behind a cast token supports HTTP `Range` identically to authenticated streaming (Chromecast issues ranged GETs aggressively while buffering and on seek). CORS: the stream and art endpoints must answer `OPTIONS` and set `Access-Control-Allow-Origin` to the receiver origin (same host, but the receiver runs in a Cast-specific context — emit the header unconditionally for `/api/v1/stream` and `/api/v1/art` with `Access-Control-Expose-Headers: Content-Range, Content-Length, Accept-Ranges`). --- ## 6. Format Negotiation & Transcoding for Cast Chromecast audio support (CAF, current firmware): FLAC (≤ 96 kHz/24-bit on most devices), AAC-LC/HE-AAC, MP3, Opus & Vorbis in WebM, WAV/LPCM. Notably **no ALAC, no WMA, no APE, no DSD**, and Ogg-contained Opus/Vorbis support is inconsistent across device generations — we treat Ogg containers as unsupported on Cast and rely on transcode. Decision procedure (sender-side, before building the queue item): ```mermaid flowchart TD T[Track codec/container
from track metadata] --> D{In Cast-safe set?
flac, mp3, aac/m4a, wav} D -- yes --> R["format=raw — direct passthrough,
server proxies ranged bytes from S3/WebDAV"] D -- no --> X["format=opus_webm&bitrate=192 →
server transcode path (doc 04 §5),
contentType audio/webm"] R --> Q[Build queue item] X --> Q ``` The sender calls `GET /api/v1/stream-decision/{trackId}?client=cast` which returns `{ "format": "raw"|"opus_webm"|"mp3", "contentType": "...", "estimatedBitrateKbps": n }`, so the codec policy lives **server-side** in one place (admins can override, e.g. force ≤ 320 kbps MP3 for old Chromecast Audio units via the `cast.transcode_profile` server setting). Transcoded Cast streams follow the chunked-transcode + seekability rules of `docs/04-caching-and-transcoding.md` §5.3 (no `Content-Length`; receiver gets `duration` from queue-item metadata so the seek bar still renders). --- ## 7. Play-History & Scrobbling During Cast The **sender** is authoritative for history (the receiver holds no user credentials). Flow: 1. Receiver emits `SCROBBLE_HINT {event:"started"}` when playback of an item first reaches 5 s. 2. Receiver emits `SCROBBLE_HINT {event:"completed"}` when position ≥ 50 % of duration or ≥ 4 minutes (same thresholds as local playback, `docs/02-data-model.md` §6 `play_history.completed`). 3. Sender posts `POST /api/v1/history` with `playbackContext: "cast"`. 4. If the sender app dies mid-cast (Cast sessions outlive senders), hints are lost — acceptable v1 limitation, noted in the milestone map. Mitigation listed for a later milestone: receiver buffers hints and replays them to the next sender that joins the session. --- ## 8. Sender Implementations ### 8.1 Android (primary) Stack: **Media3 `CastPlayer` (`androidx.media3:media3-cast`) + Google Cast SDK (`com.google.android.gms:play-services-cast-framework`)**. Targeted versions and integration detail are in `docs/08-android-app.md` §7; binding requirements here: - A single `Player` abstraction switches between local `ExoPlayer` and `CastPlayer` via `SessionAvailabilityListener`, transferring queue + position on route change (both directions: local→cast resumes at position; cast→local likewise). - `CastOptionsProvider` supplies the receiver app ID fetched from `/api/v1/system/cast-config` (cached; falls back to default receiver ID). - The custom-namespace channel (§3.3) is implemented with `Cast.CastApi`-level `setMessageReceivedCallbacks` on the `CastSession` (`com.google.android.gms.cast.framework.CastSession#setMessageReceivedCallbacks`). - The Android app answers `NEXT_ITEM_REQUEST` by calling `POST /api/v1/recommendations/next` (doc 07 §8) and then minting a cast token for the chosen track. ### 8.2 Web client CAF Sender (`cast_sender.js?loadCastFramework=1`), same message contract. The web client is a later milestone; this contract is what it codes against. ### 8.3 Session ownership & multi-sender Cast natively allows multiple senders to join a session. We accept Cast's default behavior (last write wins on queue mutations). Auto-play `NEXT_ITEM_REQUEST` is broadcast; **the first `NEXT_ITEM_RESPONSE` for a given `requestId` wins**, receivers ignore duplicates. --- ## 9. End-to-End Sequence (happy path with auto-play) ```mermaid sequenceDiagram participant U as User participant App as Android App (Sender) participant FP as FablePool Server participant CC as Chromecast (Receiver) participant ST as S3/WebDAV U->>App: Tap Cast icon, pick device App->>CC: Launch receiver app (appId from /system/cast-config) App->>FP: GET /stream-decision/trk_A?client=cast FP-->>App: {format: raw, contentType: audio/flac} App->>FP: POST /cast/token {trackIds:[A,B], includeArt:true} FP-->>App: castTokens for A, B App->>CC: queueLoad([itemA, itemB], customData.autoPlayNext=true) CC->>FP: GET /stream/trk_A?castToken=… (Range: bytes=0-) FP->>ST: ranged GET (S3 GetObject Range / WebDAV partial GET) ST-->>FP: bytes FP-->>CC: 206 Partial Content CC-->>App: MEDIA_STATUS playing CC->>App: SCROBBLE_HINT(started, A) App->>FP: POST /history {trackId:A, context:cast} Note over CC: item A ends, item B (preloaded) starts,
queueRemaining now ≤ 1 CC->>App: NEXT_ITEM_REQUEST {currentItemId:B} App->>FP: POST /recommendations/next {windowN:user setting} FP-->>App: {trackId: C, score: …} App->>FP: POST /cast/token {trackIds:[C]} FP-->>App: castToken C App->>CC: NEXT_ITEM_RESPONSE {item C} CC->>CC: queueInsert(C) ``` --- ## 10. Failure Modes & Required Handling | Failure | Required behavior | |---|---| | Server unreachable from Cast device (cert invalid / LAN isolation) | Sender detects first `MEDIA_ERROR` after `LOAD`, shows actionable error: "Your Chromecast can't reach the FablePool server — see HTTPS setup docs." Diagnostic endpoint `GET /api/v1/system/cast-reachability` returns the externally configured base URL so the app can sanity-check it before casting. | | Cast token expired (paused > 6 h) | Receiver token-refresh flow (§3.4). | | Sender disconnects mid-session | Playback of the loaded queue continues; auto-play requests time out silently → queue ends naturally. History hints lost (v1, §7). | | Backend (S3/WebDAV) stalls mid-stream | Server stream proxy applies the retry/resume policy of doc 03 §6.4; if the backend read ultimately fails, server closes the response; Chromecast retries the range once, then errors; receiver skips to next queue item rather than stopping the session. | | Recommender returns nothing (empty library edge) | `NEXT_ITEM_RESPONSE {item:null}` → queue ends. | --- ## 11. Acceptance Criteria (for the Cast implementation milestone) 1. Cast a FLAC track from the Android app to a Gen-3 Chromecast: direct passthrough, seek works, art shows. 2. Cast an ALAC track: server transcodes to Opus/WebM, plays, seek works. 3. Enable auto-play with window N=10: queue self-extends via custom namespace; verified in receiver debug logs. 4. Kill the Android app mid-track: Cast playback continues to queue end. 5. Pause 30 s, seek backwards: receiver issues ranged GETs; no full re-download (verified via server logs). 6. Default receiver fallback (no custom app ID configured): single track and static queue play; auto-play is disabled with a UI notice. 7. No long-lived credential ever appears in a media URL (log audit).