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
@@ -121,6 +121,67 @@ async def download_audio(gcs_uri: str) -> Optional[bytes]:
|
||||
return None
|
||||
|
||||
|
||||
# ── Ranged reads for /media playback (server-26#233) ────────────────────────
|
||||
# A 600 s FLAC call is ~13 MB. download_audio() above pulls the whole object
|
||||
# into memory, so /media used to make the browser wait for all of it before
|
||||
# the first sample played. These two read only what a request asks for: the
|
||||
# object size (one metadata GET) and then an inclusive byte span, which the
|
||||
# router streams out in chunks. One cached client, because the router issues
|
||||
# several span reads per playback and a fresh Client() per read re-resolves
|
||||
# credentials every time.
|
||||
_gcs_client_cache = None
|
||||
|
||||
|
||||
def _gcs_client():
|
||||
global _gcs_client_cache
|
||||
if _gcs_client_cache is None:
|
||||
from google.cloud import storage
|
||||
if settings.gcp_credentials_path:
|
||||
_gcs_client_cache = storage.Client.from_service_account_json(settings.gcp_credentials_path)
|
||||
else:
|
||||
_gcs_client_cache = storage.Client()
|
||||
return _gcs_client_cache
|
||||
|
||||
|
||||
async def audio_object_size(gcs_uri: str) -> Optional[int]:
|
||||
"""Size in bytes of a stored audio object, or None if it is missing/unreadable."""
|
||||
bucket_name, blob_path = split_gcs_uri(gcs_uri)
|
||||
if not bucket_name:
|
||||
return None
|
||||
|
||||
def _size() -> Optional[int]:
|
||||
blob = _gcs_client().bucket(bucket_name).blob(blob_path)
|
||||
blob.reload()
|
||||
return blob.size
|
||||
|
||||
try:
|
||||
size = await asyncio.to_thread(_size)
|
||||
except Exception as e:
|
||||
logger.warning(f"GCS metadata read failed for {gcs_uri}: {e}")
|
||||
return None
|
||||
return int(size) if size is not None else None
|
||||
|
||||
|
||||
async def download_audio_range(gcs_uri: str, start: int, end: int) -> Optional[bytes]:
|
||||
"""Bytes ``start``..``end`` INCLUSIVE of a stored object (GCS's own convention).
|
||||
|
||||
Reads only that span from GCS — never the whole object.
|
||||
"""
|
||||
bucket_name, blob_path = split_gcs_uri(gcs_uri)
|
||||
if not bucket_name:
|
||||
return None
|
||||
|
||||
def _read() -> bytes:
|
||||
blob = _gcs_client().bucket(bucket_name).blob(blob_path)
|
||||
return blob.download_as_bytes(start=start, end=end)
|
||||
|
||||
try:
|
||||
return await asyncio.to_thread(_read)
|
||||
except Exception as e:
|
||||
logger.warning(f"GCS ranged read failed for {gcs_uri} [{start}-{end}]: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def split_gcs_uri(gcs_uri: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""``gs://bucket/path/to.mp3`` → ``("bucket", "path/to.mp3")``."""
|
||||
if not gcs_uri or not gcs_uri.startswith("gs://"):
|
||||
|
||||
Reference in New Issue
Block a user