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

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>
This commit is contained in:
Logan Cusano
2026-10-05 02:33:25 -04:00
co-authored by Claude Opus 5.5
parent a75149b2eb
commit c836986b89
8 changed files with 787 additions and 145 deletions
+125 -22
View File
@@ -10,19 +10,100 @@ happens inline below, in the same spirit as routers/enrollment.py.
The bucket stays fully private and c2-core reads the object server-side with
Application Default Credentials, so no GCS signed URL — and therefore no
service-account private key on the VM — is involved anywhere in this path.
HTTP Range (server-26#233): the body is streamed from GCS in fixed-size span
reads instead of downloaded whole first. A 600 s FLAC call is ~13 MB, and the
old full-body response (``Accept-Ranges: none``) made the browser wait for all
of it before playback started and made seeking re-download it. Now playback
starts after the first span and a seek is a ``206`` for just the bytes needed.
Only a single range is honoured; a multi-range request gets the full body,
which RFC 9110 §14.2 permits.
"""
from fastapi import APIRouter, HTTPException, Query, Response
import re
from typing import AsyncIterator, Optional, Tuple
from fastapi import APIRouter, Header, HTTPException, Query, Response
from fastapi.responses import StreamingResponse
from app.internal import firestore as fstore
from app.internal.storage import verify_audio_link, gcs_uri_for_call, download_audio, content_type_for
from app.internal.logger import logger
from app.internal.storage import (
audio_object_size,
content_type_for,
download_audio_range,
gcs_uri_for_call,
verify_audio_link,
)
router = APIRouter(prefix="/media", tags=["media"])
# Bytes per GCS span read. ~25 s of FLAC at ~1.3 MB/min, so the first span —
# the only one the browser waits on — is one small GCS round-trip.
STREAM_CHUNK_BYTES = 512 * 1024
# Immutable content, but the URL expires — cache privately only.
_CACHE_CONTROL = "private, max-age=3600"
_RANGE_RE = re.compile(r"^\s*bytes\s*=\s*(\d*)\s*-\s*(\d*)\s*$", re.IGNORECASE)
class _Unsatisfiable(Exception):
pass
def parse_range(header: Optional[str], size: int) -> Optional[Tuple[int, int]]:
"""Resolve a ``Range`` header against an object of ``size`` bytes.
Returns an inclusive ``(start, end)``, or None when the header is absent or
should be ignored (not ``bytes``, multi-range, malformed — RFC 9110 lets a
server answer those with the full 200). Raises _Unsatisfiable for a
well-formed range that selects nothing (-> 416).
"""
if not header:
return None
m = _RANGE_RE.match(header)
if not m:
return None # other unit, multi-range ("a-b,c-d") or garbage
first, last = m.group(1), m.group(2)
if first == "" and last == "":
return None
if first == "":
# Suffix range: the final N bytes.
n = int(last)
if n == 0 or size == 0:
raise _Unsatisfiable()
return max(0, size - n), size - 1
start = int(first)
if last != "" and int(last) < start:
return None # invalid spec -> ignore the header
if start >= size:
raise _Unsatisfiable()
end = size - 1 if last == "" else min(int(last), size - 1)
return start, end
async def _stream(gcs_uri: str, first_chunk: bytes, start: int, end: int) -> AsyncIterator[bytes]:
yield first_chunk
pos = start + len(first_chunk)
while pos <= end:
chunk_end = min(pos + STREAM_CHUNK_BYTES - 1, end)
data = await download_audio_range(gcs_uri, pos, chunk_end)
if not data:
# Headers are already sent, so the status can't change; ending the
# body short makes the client see a truncated response rather than
# silently wrong audio.
logger.warning(f"/media stream cut short at byte {pos} of {gcs_uri}")
return
yield data
pos += len(data)
@router.get("/calls/{call_id}/audio")
async def get_call_audio(
call_id: str,
exp: int = Query(..., description="Link expiry, unix seconds."),
sig: str = Query(..., description="HMAC over call_id and expiry."),
range_header: Optional[str] = Header(None, alias="Range"),
):
# Verify before touching Firestore so an invalid link costs nothing.
if not verify_audio_link(call_id, exp, sig):
@@ -36,26 +117,48 @@ async def get_call_audio(
if not gcs_uri:
raise HTTPException(404, "No audio for this call.")
data = await download_audio(gcs_uri)
if not data:
size = await audio_object_size(gcs_uri)
if size is None:
raise HTTPException(404, "Audio object missing from storage.")
return Response(
content=data,
# Was hardcoded audio/mpeg. The node now uploads FLAC, and a browser
# will not play a FLAC body labelled audio/mpeg. Derived from the stored
# object's extension so old .mp3 recordings keep working unchanged.
media_type=content_type_for(gcs_uri),
headers={
"Content-Length": str(len(data)),
# Whole body at once, no Range support. This was comfortable at
# 16 kbps mono (~60 KB for a 30 s call); FLAC is ~1.3 MB/min, so a
# long call is now tens of MB and the browser must download all of
# it before playback starts. Acceptable for typical few-second
# transmissions, but this is the change that makes Range support
# actually matter — see DEFERRED.md.
"Accept-Ranges": "none",
# Immutable content, but the URL expires — cache privately only.
"Cache-Control": "private, max-age=3600",
},
# Was hardcoded audio/mpeg. The node now uploads FLAC, and a browser will
# not play a FLAC body labelled audio/mpeg. Derived from the stored
# object's extension so old .mp3 recordings keep working unchanged.
media_type = content_type_for(gcs_uri)
try:
span = parse_range(range_header, size)
except _Unsatisfiable:
return Response(
status_code=416,
headers={"Content-Range": f"bytes */{size}", "Accept-Ranges": "bytes"},
)
if size == 0:
return Response(
content=b"",
media_type=media_type,
headers={"Accept-Ranges": "bytes", "Cache-Control": _CACHE_CONTROL},
)
start, end = span if span else (0, size - 1)
# Fetched before the response starts so a read failure is still a clean
# 404 rather than a stream that dies after a 200/206 has gone out.
first = await download_audio_range(gcs_uri, start, min(start + STREAM_CHUNK_BYTES - 1, end))
if not first:
raise HTTPException(404, "Audio object missing from storage.")
headers = {
"Content-Length": str(end - start + 1),
"Accept-Ranges": "bytes",
"Cache-Control": _CACHE_CONTROL,
}
if span:
headers["Content-Range"] = f"bytes {start}-{end}/{size}"
return StreamingResponse(
_stream(gcs_uri, first, start, end),
status_code=206 if span else 200,
media_type=media_type,
headers=headers,
)