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>
165 lines
6.0 KiB
Python
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,
|
|
)
|