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:
co-authored by
Claude Opus 5.5
parent
a75149b2eb
commit
c836986b89
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user