Files
server-26/drb-c2-core/app/routers/media.py
T
Logan CusanoandClaude Opus 5.5 c836986b89 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>
2026-10-05 02:33:25 -04:00

165 lines
6.0 KiB
Python

"""
Call-audio playback.
Public router by necessity: a browser's <audio src="..."> cannot attach an
Authorization header, so the link itself carries the credential — a short-lived
HMAC over (call_id, expiry) minted by app/internal/storage.py. That is why this
router is included in main.py WITHOUT a router-level auth dependency; the check
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.
"""
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.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):
raise HTTPException(403, "Invalid or expired audio link")
call = await fstore.doc_get("calls", call_id)
if not call:
raise HTTPException(404, f"Call '{call_id}' not found.")
gcs_uri = gcs_uri_for_call(call)
if not gcs_uri:
raise HTTPException(404, "No audio for this call.")
size = await audio_object_size(gcs_uri)
if size is None:
raise HTTPException(404, "Audio object missing from storage.")
# 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,
)