Click-to-listen on the live wall + HTTP Range on /media (#233) #253

Merged
logan merged 1 commits from feat/233-wall-audio into main 2026-10-05 02:47:09 -04:00
Owner

Closes #233

What changed

Backend: routers/media.py + internal/storage.py

  • HTTP Range on /media/calls/{id}/audio: a single bytes=a-b / bytes=a- / bytes=-n returns 206 with Content-Range, Content-Length and Accept-Ranges: bytes. An unsatisfiable range returns 416 (Content-Range: bytes */size). No Range returns 200 with the full body and Accept-Ranges: bytes. A multi-range, non-bytes or malformed header is ignored and gets a 200, which RFC 9110 permits.
  • The whole object is no longer downloaded before responding. audio_object_size() (blob.reload()) and download_audio_range() (download_as_bytes(start=, end=), in a thread) read only the requested bytes. The body is a StreamingResponse made of 512 KiB span reads. The first span is fetched before headers go out, so a read failure is still a clean 404.
  • The HMAC check (verify_audio_link) and the Firestore lookup are unchanged and still run first. A bad signature returns 403 before any Firestore or GCS call (tested).
  • Content-Type is still content_type_for(gcs_uri). Cache-Control is unchanged. The comment that cited DEFERRED.md is replaced.
  • storage.download_audio() is now unused. I left it in place.

Frontend

  • components/InlinePlayer.tsx (new): InlinePlayer moved out of CallSpineEntry.tsx and exported, plus a smaller PlayButton. CallSpineEntry imports it.
  • lib/audioQueue.ts (new): a useSyncExternalStore store over one module-level <audio> element, so only one call plays at a time anywhere. Actions: play(callId), playIncident(idsOldestFirst), stop(), plus pause, resume, seek and toggle. Components subscribe to primitive slices only, so the map does not re-render on timeupdate. The playback URL is minted per play through GET /calls/{id}, as before. Audio stops when the last player unmounts.
  • WallPanels.tsx IncidentPanel: a play button on each spine row that has audio, and a Play incident button that queues the loaded calls oldest first (it changes to Stop while one is playing). The playing row is highlighted and grows a seek bar.
  • #239 spine indicator: the silent slice(0, 30) now shows "Showing latest 30 of M" (M = max(call_ids, loaded)) with a "show all loaded" toggle. When call_ids > loaded, it says how many older calls are outside the loaded window. #239's other items (per-incident fetch, the scrubber, the #inc= fallback) are not in this PR.
  • MapView.tsx: a play button in each path-stop popup (MachineOutputNotice kept), and "play latest" on rail cards, placed as a sibling of the card because a button cannot nest inside a button. The path stop whose call is playing gets an accent ring and is raised above the other markers.
  • Tier gating: one helper, canPlayAudio() in lib/audioQueue.ts. It is true for any signed-in org member (or platform admin) today. Its comment points at BUSINESS_MODEL.md §3 / Gate A so the Public tier can be switched off in that one place. I did not add a tier concept.
  • Nothing autoplays on load. The store is inert until a click. The existing "play once the URL lands" after a click is kept.

#109 item 2 (conditional hook)

This was already fixed on main. The pre-move InlinePlayer had no hasAudio prop or guard, and its only early return (if (error)) came after every hook. No change was needed. The new components keep every hook before any return.

Not touched

  • Recording, trim and call-boundary paths: no change to call_recorder, metadata_watcher or upload.
  • Not touched: LiveView.tsx, c2api.ts, types.ts, main.py, config.py, the discord files.

Verification

  • pytest in drb-c2-core: 778 passed, including the new tests/test_media_range.py (21 tests). It covers bounded, open-ended and suffix ranges, end clamping, 416, the no-Range 200, ignored headers, a 13 MB object streamed in spans of 512 KiB or less (only the requested bytes are read), 403 before any lookup for a bad or expired signature, 404 for a missing object, the legacy .mp3 content type, and the storage helpers calling reload() and download_as_bytes(start=, end=) and never a whole-object read.
  • tsc --noEmit: exit 0.
  • Not measured against real GCS. The "< 2 s start for a 600 s FLAC" criterion is supported by design (playback waits on one 512 KiB span read plus one metadata GET) and by the span test, but nobody has timed it in a browser against the deployed bucket. Check it after deploy: DevTools Network should show the /media request as 206.

🤖 Generated with Claude Code

Closes #233 ## What changed **Backend: `routers/media.py` + `internal/storage.py`** - HTTP Range on `/media/calls/{id}/audio`: a single `bytes=a-b` / `bytes=a-` / `bytes=-n` returns **206** with `Content-Range`, `Content-Length` and `Accept-Ranges: bytes`. An unsatisfiable range returns **416** (`Content-Range: bytes */size`). No Range returns **200** with the full body and `Accept-Ranges: bytes`. A multi-range, non-`bytes` or malformed header is ignored and gets a 200, which RFC 9110 permits. - The whole object is no longer downloaded before responding. `audio_object_size()` (`blob.reload()`) and `download_audio_range()` (`download_as_bytes(start=, end=)`, in a thread) read only the requested bytes. The body is a `StreamingResponse` made of 512 KiB span reads. The first span is fetched before headers go out, so a read failure is still a clean 404. - The HMAC check (`verify_audio_link`) and the Firestore lookup are unchanged and still run first. A bad signature returns 403 before any Firestore or GCS call (tested). - `Content-Type` is still `content_type_for(gcs_uri)`. `Cache-Control` is unchanged. The comment that cited DEFERRED.md is replaced. - `storage.download_audio()` is now unused. I left it in place. **Frontend** - `components/InlinePlayer.tsx` (new): `InlinePlayer` moved out of `CallSpineEntry.tsx` and exported, plus a smaller `PlayButton`. `CallSpineEntry` imports it. - `lib/audioQueue.ts` (new): a `useSyncExternalStore` store over **one** module-level `<audio>` element, so only one call plays at a time anywhere. Actions: `play(callId)`, `playIncident(idsOldestFirst)`, `stop()`, plus `pause`, `resume`, `seek` and `toggle`. Components subscribe to primitive slices only, so the map does not re-render on `timeupdate`. The playback URL is minted per play through `GET /calls/{id}`, as before. Audio stops when the last player unmounts. - `WallPanels.tsx` IncidentPanel: a play button on each spine row that has audio, and a **Play incident** button that queues the loaded calls oldest first (it changes to Stop while one is playing). The playing row is highlighted and grows a seek bar. - **#239 spine indicator**: the silent `slice(0, 30)` now shows "Showing latest 30 of M" (M = max(call_ids, loaded)) with a "show all loaded" toggle. When `call_ids` > loaded, it says how many older calls are outside the loaded window. #239's other items (per-incident fetch, the scrubber, the `#inc=` fallback) are not in this PR. - `MapView.tsx`: a play button in each path-stop popup (MachineOutputNotice kept), and "play latest" on rail cards, placed as a sibling of the card because a button cannot nest inside a button. The path stop whose call is playing gets an accent ring and is raised above the other markers. - **Tier gating**: one helper, `canPlayAudio()` in `lib/audioQueue.ts`. It is true for any signed-in org member (or platform admin) today. Its comment points at BUSINESS_MODEL.md §3 / Gate A so the Public tier can be switched off in that one place. I did not add a tier concept. - **Nothing autoplays on load.** The store is inert until a click. The existing "play once the URL lands" after a click is kept. ## #109 item 2 (conditional hook) This was already fixed on main. The pre-move `InlinePlayer` had no `hasAudio` prop or guard, and its only early return (`if (error)`) came after every hook. No change was needed. The new components keep every hook before any return. ## Not touched - Recording, trim and call-boundary paths: no change to `call_recorder`, `metadata_watcher` or upload. - Not touched: `LiveView.tsx`, `c2api.ts`, `types.ts`, `main.py`, `config.py`, the discord files. ## Verification - `pytest` in drb-c2-core: **778 passed**, including the new `tests/test_media_range.py` (21 tests). It covers bounded, open-ended and suffix ranges, end clamping, 416, the no-Range 200, ignored headers, a 13 MB object streamed in spans of 512 KiB or less (only the requested bytes are read), 403 before any lookup for a bad or expired signature, 404 for a missing object, the legacy `.mp3` content type, and the storage helpers calling `reload()` and `download_as_bytes(start=, end=)` and never a whole-object read. - `tsc --noEmit`: exit 0. - **Not measured against real GCS.** The "< 2 s start for a 600 s FLAC" criterion is supported by design (playback waits on one 512 KiB span read plus one metadata GET) and by the span test, but nobody has timed it in a browser against the deployed bucket. Check it after deploy: DevTools Network should show the `/media` request as 206. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
logan added 1 commit 2026-10-05 02:34:10 -04:00
Backend: /media/calls/{id}/audio now honours a single HTTP Range
(bytes=a-b, a-, -n) with 206 + Content-Range, 416 for unsatisfiable,
and a 200 full body (Accept-Ranges: bytes) otherwise. The body is
streamed from GCS in 512 KiB span reads (storage.audio_object_size +
download_audio_range) instead of downloading the whole object first,
so a 600 s FLAC starts after one span and seeks fetch only what they
need. HMAC check and Firestore lookup unchanged and still first.

Frontend: InlinePlayer moved to components/InlinePlayer.tsx and now
drives lib/audioQueue.ts, a useSyncExternalStore store over ONE audio
element (one call plays at a time). Wall spine rows get play buttons
and a "Play incident" queue (oldest first) with the playing row
highlighted; map path-stop popups get a play button and the playing
stop gets an accent ring; rail cards get "play latest". Playback is
gated by canPlayAudio() (BUSINESS_MODEL section 3 / Gate A hook).
Nothing plays until clicked. Spine truncation is now stated
("showing latest 30 of M", show-all toggle, count outside the loaded
window) for server-26#239.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
logan merged commit 18c2aeeff2 into main 2026-10-05 02:47:09 -04:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: logan/server-26#253