audio/mpeg was hardcoded at both points call audio is written and served, from back when the node produced nothing but 16 kbps MP3. It now uploads FLAC, and a browser will not play a FLAC body labelled audio/mpeg. storage.py grows one extension -> Content-Type map, used by the GCS upload and by /media. Keyed off the object's real extension, so every existing .mp3 recording keeps working with no migration -- and _safe_audio_filename already accepted .flac, so object naming needed nothing. Also flags what this costs: /media sends the whole body with Accept-Ranges: none, which was fine at ~60 KB per call and is not fine at ~1.3 MB/min. Noted at the header and in DEFERRED.md, whose stated reason for deferring Range support was the old file size. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
62 lines
2.7 KiB
Python
62 lines
2.7 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.
|
|
"""
|
|
from fastapi import APIRouter, HTTPException, Query, Response
|
|
from app.internal import firestore as fstore
|
|
from app.internal.storage import verify_audio_link, gcs_uri_for_call, download_audio, content_type_for
|
|
|
|
router = APIRouter(prefix="/media", tags=["media"])
|
|
|
|
|
|
@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."),
|
|
):
|
|
# 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.")
|
|
|
|
data = await download_audio(gcs_uri)
|
|
if not data:
|
|
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",
|
|
},
|
|
)
|