Merge pull request 'Click-to-listen on the live wall + HTTP Range on /media (#233)' (#253) from feat/233-wall-audio into main
Build & Deploy / Build & push images (push) Failing after 10s
Build & Deploy / Deploy to VM (push) Skipped
Build & Deploy / Deploy Firestore rules & indexes (push) Skipped
Build & Deploy / Report a failed deploy (push) Successful in 1s

This commit was merged in pull request #253.
This commit is contained in:
2026-10-05 02:47:08 -04:00
8 changed files with 787 additions and 145 deletions
+61
View File
@@ -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://"):