Serve call audio through c2-core instead of GCS signed URLs
upload_audio() could only sign a URL when GCP_CREDENTIALS_PATH pointed at a service-account key file. The deployed VM runs on Application Default Credentials with no key file, so every upload silently took the fallback branch and returned a bare gs:// URI. That broke two things at once: * Browsers cannot fetch a gs:// URI, so no recording was ever playable. * _public_url_to_gcs_uri() only matched https://storage.googleapis.com/ and returned None for it, so `if gcs_uri:` in the upload path was always false and transcription never ran. Nothing was logged, which is why this looked like an OpenAI credits problem rather than a storage one. The fallback also interpolated the client-supplied filename instead of the call_id-derived safe name, so the URI did not even name the object written. Calls now store only the canonical gs:// location. A short-lived playback link is minted per read as an HMAC over (call_id, expiry) keyed by SERVICE_KEY, and audio is served from the private bucket by the new /media route. An <audio src> cannot carry an Authorization header, so the link has to be the credential; that router is therefore public with the check done inline, as enrollment.py already does. Signing GCS URLs from the VM would have needed a serviceAccountTokenCreator grant on its own service account — this avoids the IAM change entirely and keeps the bucket private. gcs_uri_for_call() reconstructs the object name from call_id, so recordings made before this fix are reachable again without a data migration. Frontend rows come straight from Firestore via onSnapshot and never see a server-minted field, so CallRow fetches the link lazily on expand. Also removes the last long-lived (1 year) signed URL and the log line that printed it.
This commit is contained in:
@@ -68,6 +68,16 @@ class Settings(BaseSettings):
|
|||||||
# Upload size limit — reject audio files larger than this (bytes). Default 100 MB.
|
# Upload size limit — reject audio files larger than this (bytes). Default 100 MB.
|
||||||
upload_max_bytes: int = 100 * 1024 * 1024
|
upload_max_bytes: int = 100 * 1024 * 1024
|
||||||
|
|
||||||
|
# Public origin this API is reachable on, e.g. "https://api.drb.example.com".
|
||||||
|
# Only used to build absolute call-audio playback links: an <audio src> is
|
||||||
|
# fetched by the browser directly, so a relative path would resolve against
|
||||||
|
# the frontend origin, not this one.
|
||||||
|
public_api_url: Optional[str] = None
|
||||||
|
|
||||||
|
# How long a minted call-audio playback link stays valid. Long enough for a
|
||||||
|
# browsing session, short enough that a copied link isn't durable access.
|
||||||
|
audio_link_ttl_seconds: int = 6 * 60 * 60
|
||||||
|
|
||||||
# CORS — set to your frontend origin(s) in production, e.g. ["https://app.example.com"]
|
# CORS — set to your frontend origin(s) in production, e.g. ["https://app.example.com"]
|
||||||
# Defaults to "*" for local development only.
|
# Defaults to "*" for local development only.
|
||||||
cors_origins: list[str] = ["*"]
|
cors_origins: list[str] = ["*"]
|
||||||
|
|||||||
@@ -1,9 +1,40 @@
|
|||||||
|
"""
|
||||||
|
Call-audio storage and playback links.
|
||||||
|
|
||||||
|
TWO THINGS THIS MODULE DELIBERATELY DOES NOT DO ANY MORE:
|
||||||
|
|
||||||
|
1. It does not return a GCS *signed* URL from the upload path. Signing needs a
|
||||||
|
service-account private key, and the deployed VM runs on Application Default
|
||||||
|
Credentials with no key file (see ansible c2-core.env.j2). The old code
|
||||||
|
silently fell back to returning a bare ``gs://`` URI, which broke two things
|
||||||
|
at once: browsers can't fetch a gs:// URI, so no recording was ever
|
||||||
|
playable, and ``_public_url_to_gcs_uri`` in upload.py returned None for it,
|
||||||
|
so the transcription step was skipped without logging anything at all.
|
||||||
|
|
||||||
|
2. It does not store a long-lived URL on the call document. What gets persisted
|
||||||
|
is the canonical ``gs://`` object location; a short-lived playback link is
|
||||||
|
minted on read instead. Nothing durable and nothing loggable is a credential.
|
||||||
|
|
||||||
|
Playback goes through c2-core's own /media route rather than GCS directly,
|
||||||
|
because an <audio src> cannot carry an Authorization header — so the link
|
||||||
|
itself has to be the credential. It is a plain HMAC over (call_id, expiry)
|
||||||
|
keyed by SERVICE_KEY, which costs no network round-trip, keeps the bucket
|
||||||
|
fully private, and needs no IAM change on the VM's service account.
|
||||||
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import datetime
|
import hashlib
|
||||||
from typing import Optional
|
import hmac
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Optional, Tuple
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.internal.logger import logger
|
from app.internal.logger import logger
|
||||||
|
|
||||||
|
# Domain separation: the audio-link key is derived from SERVICE_KEY rather than
|
||||||
|
# being SERVICE_KEY itself, so a leaked playback link can never be replayed as
|
||||||
|
# a service-key bearer token against the rest of the API.
|
||||||
|
_KEY_CONTEXT = b"drb-audio-link-v1"
|
||||||
|
|
||||||
|
|
||||||
def _safe_audio_filename(filename: str, call_id: str) -> str:
|
def _safe_audio_filename(filename: str, call_id: str) -> str:
|
||||||
"""Return a safe GCS object name derived from the call_id.
|
"""Return a safe GCS object name derived from the call_id.
|
||||||
@@ -12,7 +43,6 @@ def _safe_audio_filename(filename: str, call_id: str) -> str:
|
|||||||
call_id (which we control) to prevent path traversal via crafted filenames.
|
call_id (which we control) to prevent path traversal via crafted filenames.
|
||||||
The original extension is preserved only if it's a known audio type.
|
The original extension is preserved only if it's a known audio type.
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
ext = os.path.splitext(filename)[-1].lower() if filename else ""
|
ext = os.path.splitext(filename)[-1].lower() if filename else ""
|
||||||
if ext not in (".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"):
|
if ext not in (".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"):
|
||||||
ext = ".mp3"
|
ext = ".mp3"
|
||||||
@@ -20,38 +50,140 @@ def _safe_audio_filename(filename: str, call_id: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
async def upload_audio(data: bytes, filename: str, call_id: str = "") -> Optional[str]:
|
async def upload_audio(data: bytes, filename: str, call_id: str = "") -> Optional[str]:
|
||||||
"""Upload audio bytes to GCS and return a signed URL, or None if disabled."""
|
"""Upload audio bytes to GCS and return the canonical gs:// URI, or None if disabled."""
|
||||||
if not settings.gcs_bucket:
|
if not settings.gcs_bucket:
|
||||||
logger.info("GCS_BUCKET not configured — skipping audio upload.")
|
logger.info("GCS_BUCKET not configured — skipping audio upload.")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _upload() -> str:
|
safe_name = _safe_audio_filename(filename, call_id)
|
||||||
|
blob_path = f"calls/{safe_name}"
|
||||||
|
|
||||||
|
def _upload() -> None:
|
||||||
from google.cloud import storage
|
from google.cloud import storage
|
||||||
from google.oauth2 import service_account as sa
|
|
||||||
if settings.gcp_credentials_path:
|
if settings.gcp_credentials_path:
|
||||||
client = storage.Client.from_service_account_json(settings.gcp_credentials_path)
|
client = storage.Client.from_service_account_json(settings.gcp_credentials_path)
|
||||||
signing_creds = sa.Credentials.from_service_account_file(settings.gcp_credentials_path)
|
|
||||||
else:
|
else:
|
||||||
client = storage.Client()
|
client = storage.Client()
|
||||||
signing_creds = None
|
blob = client.bucket(settings.gcs_bucket).blob(blob_path)
|
||||||
bucket = client.bucket(settings.gcs_bucket)
|
|
||||||
safe_name = _safe_audio_filename(filename, call_id)
|
|
||||||
blob = bucket.blob(f"calls/{safe_name}")
|
|
||||||
blob.upload_from_string(data, content_type="audio/mpeg")
|
blob.upload_from_string(data, content_type="audio/mpeg")
|
||||||
if signing_creds:
|
|
||||||
return blob.generate_signed_url(
|
|
||||||
version="v2",
|
|
||||||
expiration=datetime.timedelta(days=365),
|
|
||||||
method="GET",
|
|
||||||
credentials=signing_creds,
|
|
||||||
)
|
|
||||||
# Fallback: return the gs:// URI (no public access)
|
|
||||||
return f"gs://{settings.gcs_bucket}/calls/{filename}"
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
url = await asyncio.to_thread(_upload)
|
await asyncio.to_thread(_upload)
|
||||||
logger.info(f"Audio uploaded: {url}")
|
|
||||||
return url
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"GCS upload failed: {e}")
|
logger.error(f"GCS upload failed: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
gcs_uri = f"gs://{settings.gcs_bucket}/{blob_path}"
|
||||||
|
logger.info(f"Audio uploaded: {gcs_uri}")
|
||||||
|
return gcs_uri
|
||||||
|
|
||||||
|
|
||||||
|
async def download_audio(gcs_uri: str) -> Optional[bytes]:
|
||||||
|
"""Fetch an object back out of GCS. Server-side read — no signing involved."""
|
||||||
|
bucket_name, blob_path = split_gcs_uri(gcs_uri)
|
||||||
|
if not bucket_name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _download() -> bytes:
|
||||||
|
from google.cloud import storage
|
||||||
|
if settings.gcp_credentials_path:
|
||||||
|
client = storage.Client.from_service_account_json(settings.gcp_credentials_path)
|
||||||
|
else:
|
||||||
|
client = storage.Client()
|
||||||
|
return client.bucket(bucket_name).blob(blob_path).download_as_bytes()
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(_download)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"GCS download failed for {gcs_uri}: {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://"):
|
||||||
|
return None, None
|
||||||
|
without_scheme = gcs_uri[len("gs://"):]
|
||||||
|
if "/" not in without_scheme:
|
||||||
|
return None, None
|
||||||
|
bucket_name, blob_path = without_scheme.split("/", 1)
|
||||||
|
return bucket_name, blob_path
|
||||||
|
|
||||||
|
|
||||||
|
def gcs_uri_for_call(call: dict) -> Optional[str]:
|
||||||
|
"""Resolve the audio object for a call document.
|
||||||
|
|
||||||
|
Prefers the canonical ``audio_gcs_uri`` written by /upload. Falls back to
|
||||||
|
reconstructing the object name from the call_id for documents written
|
||||||
|
before this module was fixed: those stored a gs:// URI built from the
|
||||||
|
*client-supplied* filename, which never matched the object actually
|
||||||
|
written (always ``calls/{call_id}.mp3``). Reconstructing rather than
|
||||||
|
trusting the stored value is what makes every pre-existing recording
|
||||||
|
playable again without a data migration.
|
||||||
|
"""
|
||||||
|
uri = call.get("audio_gcs_uri")
|
||||||
|
if uri:
|
||||||
|
return uri
|
||||||
|
call_id = call.get("call_id")
|
||||||
|
if call.get("audio_url") and call_id and settings.gcs_bucket:
|
||||||
|
return f"gs://{settings.gcs_bucket}/calls/{call_id}.mp3"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _link_key() -> Optional[bytes]:
|
||||||
|
if not settings.service_key:
|
||||||
|
return None
|
||||||
|
return hmac.new(settings.service_key.encode("utf-8"), _KEY_CONTEXT, hashlib.sha256).digest()
|
||||||
|
|
||||||
|
|
||||||
|
def sign_audio_link(call_id: str, expires_at: int) -> Optional[str]:
|
||||||
|
key = _link_key()
|
||||||
|
if not key:
|
||||||
|
return None
|
||||||
|
msg = f"{call_id}:{expires_at}".encode("utf-8")
|
||||||
|
return hmac.new(key, msg, hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_audio_link(call_id: str, expires_at: int, signature: str) -> bool:
|
||||||
|
if expires_at < int(time.time()):
|
||||||
|
return False
|
||||||
|
expected = sign_audio_link(call_id, expires_at)
|
||||||
|
if not expected:
|
||||||
|
return False
|
||||||
|
return hmac.compare_digest(expected, signature)
|
||||||
|
|
||||||
|
|
||||||
|
_warned_no_service_key = False
|
||||||
|
_warned_no_public_url = False
|
||||||
|
|
||||||
|
|
||||||
|
def playback_url(call: dict) -> Optional[str]:
|
||||||
|
"""Mint a short-lived playback URL for a call, or None if it has no audio."""
|
||||||
|
global _warned_no_service_key, _warned_no_public_url
|
||||||
|
|
||||||
|
call_id = call.get("call_id")
|
||||||
|
if not call_id or not gcs_uri_for_call(call):
|
||||||
|
return None
|
||||||
|
|
||||||
|
expires_at = int(time.time()) + settings.audio_link_ttl_seconds
|
||||||
|
signature = sign_audio_link(call_id, expires_at)
|
||||||
|
if not signature:
|
||||||
|
if not _warned_no_service_key:
|
||||||
|
logger.error("SERVICE_KEY not set — call audio cannot be served.")
|
||||||
|
_warned_no_service_key = True
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Loud rather than silent: a relative link here would 404 against the
|
||||||
|
# frontend origin, which is the exact failure mode this module exists to
|
||||||
|
# stop repeating. Deploy via ansible so c2-core.env.j2 sets PUBLIC_API_URL.
|
||||||
|
if not settings.public_api_url and not _warned_no_public_url:
|
||||||
|
logger.error("PUBLIC_API_URL not set — call audio links will be relative and will not resolve.")
|
||||||
|
_warned_no_public_url = True
|
||||||
|
|
||||||
|
base = (settings.public_api_url or "").rstrip("/")
|
||||||
|
return f"{base}/media/calls/{call_id}/audio?exp={expires_at}&sig={signature}"
|
||||||
|
|
||||||
|
|
||||||
|
def with_playback_url(call: dict) -> dict:
|
||||||
|
"""Return the call dict with a freshly minted ``audio_url``."""
|
||||||
|
return {**call, "audio_url": playback_url(call)}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from app.internal.auth import (
|
|||||||
require_node_service_or_firebase_token,
|
require_node_service_or_firebase_token,
|
||||||
)
|
)
|
||||||
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
|
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
|
||||||
from app.routers import enrollment
|
from app.routers import enrollment, media
|
||||||
from app.internal import dynsec
|
from app.internal import dynsec
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
|
|
||||||
@@ -101,6 +101,9 @@ app.include_router(admin.router) # auth is per-endpoint (read: firebase, wri
|
|||||||
app.include_router(users.router) # auth: admin only
|
app.include_router(users.router) # auth: admin only
|
||||||
app.include_router(links.router) # auth is per-endpoint (generate: firebase, resolve: service key)
|
app.include_router(links.router) # auth is per-endpoint (generate: firebase, resolve: service key)
|
||||||
app.include_router(enrollment.router) # public; auth is the enrollment/pickup-secret tokens, checked inline
|
app.include_router(enrollment.router) # public; auth is the enrollment/pickup-secret tokens, checked inline
|
||||||
|
# public by necessity — an <audio src> can't send a bearer token, so the
|
||||||
|
# short-lived HMAC in the URL is the credential. Checked inline in media.py.
|
||||||
|
app.include_router(media.router)
|
||||||
# NOTE: there used to be an app.routers.mqtt_auth router here (an HTTP
|
# NOTE: there used to be an app.routers.mqtt_auth router here (an HTTP
|
||||||
# backend for the mosquitto-go-auth plugin). That plugin's upstream project
|
# backend for the mosquitto-go-auth plugin). That plugin's upstream project
|
||||||
# is archived (no CVE patches) and was rejected for an internet-facing
|
# is archived (no CVE patches) and was rejected for an internet-facing
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ class CallRecord(BaseModel):
|
|||||||
srcaddr: Optional[str] = None
|
srcaddr: Optional[str] = None
|
||||||
started_at: datetime
|
started_at: datetime
|
||||||
ended_at: Optional[datetime] = None
|
ended_at: Optional[datetime] = None
|
||||||
audio_url: Optional[str] = None
|
audio_gcs_uri: Optional[str] = None # canonical gs:// object location
|
||||||
|
audio_url: Optional[str] = None # NOT stored — minted per read, see internal/storage.py
|
||||||
transcript: Optional[str] = None # populated later by STT
|
transcript: Optional[str] = None # populated later by STT
|
||||||
incident_ids: List[str] = [] # one per scene detected in the recording
|
incident_ids: List[str] = [] # one per scene detected in the recording
|
||||||
location: Optional[Dict[str, float]] = None # {lat, lng}
|
location: Optional[Dict[str, float]] = None # {lat, lng}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from pydantic import BaseModel
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.internal.auth import require_admin_token
|
from app.internal.auth import require_admin_token
|
||||||
|
from app.internal.storage import gcs_uri_for_call, with_playback_url
|
||||||
|
|
||||||
|
|
||||||
class TranscriptUpdate(BaseModel):
|
class TranscriptUpdate(BaseModel):
|
||||||
@@ -25,7 +26,9 @@ async def list_calls(
|
|||||||
filters["status"] = status
|
filters["status"] = status
|
||||||
if system_id:
|
if system_id:
|
||||||
filters["system_id"] = system_id
|
filters["system_id"] = system_id
|
||||||
return await fstore.collection_list("calls", **filters)
|
calls = await fstore.collection_list("calls", **filters)
|
||||||
|
# audio_url is not stored — it's a short-lived signed link minted per read.
|
||||||
|
return [with_playback_url(c) for c in calls]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{call_id}")
|
@router.get("/{call_id}")
|
||||||
@@ -33,7 +36,7 @@ async def get_call(call_id: str):
|
|||||||
call = await fstore.doc_get("calls", call_id)
|
call = await fstore.doc_get("calls", call_id)
|
||||||
if not call:
|
if not call:
|
||||||
raise HTTPException(404, f"Call '{call_id}' not found.")
|
raise HTTPException(404, f"Call '{call_id}' not found.")
|
||||||
return call
|
return with_playback_url(call)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{call_id}/reprocess")
|
@router.post("/{call_id}/reprocess")
|
||||||
@@ -43,10 +46,9 @@ async def reprocess_call(call_id: str, background_tasks: BackgroundTasks):
|
|||||||
if not call:
|
if not call:
|
||||||
raise HTTPException(404, f"Call '{call_id}' not found.")
|
raise HTTPException(404, f"Call '{call_id}' not found.")
|
||||||
|
|
||||||
from app.routers.upload import _run_intelligence_pipeline, _public_url_to_gcs_uri
|
from app.routers.upload import _run_intelligence_pipeline
|
||||||
|
|
||||||
audio_url = call.get("audio_url")
|
gcs_uri = gcs_uri_for_call(call)
|
||||||
gcs_uri = _public_url_to_gcs_uri(audio_url) if audio_url else None
|
|
||||||
|
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
_run_intelligence_pipeline,
|
_run_intelligence_pipeline,
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
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,
|
||||||
|
media_type="audio/mpeg",
|
||||||
|
headers={
|
||||||
|
"Content-Length": str(len(data)),
|
||||||
|
# Recordings are small (16 kbps mono — a 30s call is ~60 KB), so the
|
||||||
|
# whole body is sent at once and the browser seeks within its own
|
||||||
|
# buffer. Range support would only matter for long files.
|
||||||
|
"Accept-Ranges": "none",
|
||||||
|
# Immutable content, but the URL expires — cache privately only.
|
||||||
|
"Cache-Control": "private, max-age=3600",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -47,16 +47,15 @@ async def upload_call_audio(
|
|||||||
if len(data) > settings.upload_max_bytes:
|
if len(data) > settings.upload_max_bytes:
|
||||||
raise HTTPException(413, f"File too large (max {settings.upload_max_bytes // (1024*1024)} MB).")
|
raise HTTPException(413, f"File too large (max {settings.upload_max_bytes // (1024*1024)} MB).")
|
||||||
|
|
||||||
audio_url = await upload_audio(data, file.filename or "", call_id=call_id)
|
gcs_uri = await upload_audio(data, file.filename or "", call_id=call_id)
|
||||||
|
|
||||||
if audio_url:
|
if gcs_uri:
|
||||||
try:
|
try:
|
||||||
await fstore.doc_set("calls", call_id, {"audio_url": audio_url})
|
# Canonical object location only. The playback link is minted per
|
||||||
|
# read in storage.playback_url() — nothing durable is stored here.
|
||||||
|
await fstore.doc_set("calls", call_id, {"audio_gcs_uri": gcs_uri})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not update call {call_id} with audio_url: {e}")
|
logger.warning(f"Could not update call {call_id} with audio_gcs_uri: {e}")
|
||||||
|
|
||||||
# Convert public GCS URL to gs:// URI for Speech-to-Text
|
|
||||||
gcs_uri = _public_url_to_gcs_uri(audio_url)
|
|
||||||
|
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
_run_intelligence_pipeline,
|
_run_intelligence_pipeline,
|
||||||
@@ -68,21 +67,7 @@ async def upload_call_audio(
|
|||||||
gcs_uri=gcs_uri,
|
gcs_uri=gcs_uri,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"url": audio_url}
|
return {"url": gcs_uri}
|
||||||
|
|
||||||
|
|
||||||
def _public_url_to_gcs_uri(url: str) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Convert a public GCS URL (possibly signed) like
|
|
||||||
https://storage.googleapis.com/bucket/calls/file.mp3?Expires=...
|
|
||||||
to a gs:// URI usable by Speech-to-Text.
|
|
||||||
Returns None if the URL doesn't look like a GCS URL.
|
|
||||||
"""
|
|
||||||
prefix = "https://storage.googleapis.com/"
|
|
||||||
if url and url.startswith(prefix):
|
|
||||||
path = url[len(prefix):].split("?")[0] # strip signed-URL query params
|
|
||||||
return "gs://" + path
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _correlate_with_consensus(
|
async def _correlate_with_consensus(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { CallRecord } from "@/lib/types";
|
import type { CallRecord } from "@/lib/types";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
|
|
||||||
@@ -37,11 +37,31 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
: call.incident_id ? [call.incident_id] : [];
|
: call.incident_id ? [call.incident_id] : [];
|
||||||
|
|
||||||
const isActive = call.status === "active";
|
const isActive = call.status === "active";
|
||||||
const hasDetails = call.transcript || call.transcript_corrected || (call.tags && call.tags.length > 0) || incidentIds.length > 0 || call.audio_url;
|
// Rows come straight from Firestore (lib/useCalls.ts), and the doc only holds
|
||||||
|
// the private gs:// object location — never a playable URL. Presence of audio
|
||||||
|
// is known from the doc; the actual link is minted by the API on expand.
|
||||||
|
// audio_url is the legacy field: older docs stored an (unplayable) gs:// URI
|
||||||
|
// there, so it still signals "this call has a recording".
|
||||||
|
const hasAudio = !!(call.audio_gcs_uri || call.audio_url);
|
||||||
|
const hasDetails = call.transcript || call.transcript_corrected || (call.tags && call.tags.length > 0) || incidentIds.length > 0 || hasAudio;
|
||||||
const displayTranscript = (!showOriginal && call.transcript_corrected) ? call.transcript_corrected : call.transcript;
|
const displayTranscript = (!showOriginal && call.transcript_corrected) ? call.transcript_corrected : call.transcript;
|
||||||
const hasBoth = !!(call.transcript && call.transcript_corrected);
|
const hasBoth = !!(call.transcript && call.transcript_corrected);
|
||||||
const hasSegments = call.segments && call.segments.length > 1;
|
const hasSegments = call.segments && call.segments.length > 1;
|
||||||
|
|
||||||
|
// Fetched lazily on expand: playback links are short-lived, so minting one
|
||||||
|
// for every row up front would waste most of them and expire the rest.
|
||||||
|
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||||
|
const [audioError, setAudioError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!expanded || !hasAudio || audioUrl || audioError) return;
|
||||||
|
let cancelled = false;
|
||||||
|
c2api.getCall(call.call_id)
|
||||||
|
.then((full) => { if (!cancelled) setAudioUrl(full.audio_url ?? null); })
|
||||||
|
.catch(() => { if (!cancelled) setAudioError(true); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [expanded, hasAudio, audioUrl, audioError, call.call_id]);
|
||||||
|
|
||||||
function startEdit() {
|
function startEdit() {
|
||||||
setEditText(call.transcript_corrected ?? call.transcript ?? "");
|
setEditText(call.transcript_corrected ?? call.transcript ?? "");
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
@@ -89,7 +109,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-xs">
|
<td className="px-4 py-2 text-xs">
|
||||||
{call.audio_url ? (
|
{hasAudio ? (
|
||||||
<span className="text-blue-400">▶</span>
|
<span className="text-blue-400">▶</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-700">—</span>
|
<span className="text-gray-700">—</span>
|
||||||
@@ -104,13 +124,19 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
<tr className="bg-gray-900/60 border-b border-gray-800">
|
<tr className="bg-gray-900/60 border-b border-gray-800">
|
||||||
<td colSpan={7} className="px-6 py-3 space-y-2">
|
<td colSpan={7} className="px-6 py-3 space-y-2">
|
||||||
{/* Audio player */}
|
{/* Audio player */}
|
||||||
{call.audio_url && (
|
{hasAudio && (
|
||||||
|
audioError ? (
|
||||||
|
<p className="text-xs text-red-400 font-mono">Could not load audio.</p>
|
||||||
|
) : audioUrl ? (
|
||||||
<audio
|
<audio
|
||||||
controls
|
controls
|
||||||
src={call.audio_url}
|
src={audioUrl}
|
||||||
className="w-full max-w-sm h-8"
|
className="w-full max-w-sm h-8"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-600 font-mono">Loading audio…</p>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
|
|||||||
@@ -93,6 +93,13 @@ export interface CallRecord {
|
|||||||
freq: number | null;
|
freq: number | null;
|
||||||
started_at: string;
|
started_at: string;
|
||||||
ended_at: string | null;
|
ended_at: string | null;
|
||||||
|
/** Private gs:// object location. Present on the Firestore doc; not playable. */
|
||||||
|
audio_gcs_uri?: string | null;
|
||||||
|
/**
|
||||||
|
* Short-lived playback link. Minted per read by the API — only populated on
|
||||||
|
* calls fetched via c2api, never on docs read straight from Firestore.
|
||||||
|
* On pre-fix docs this holds a legacy (unplayable) gs:// URI instead.
|
||||||
|
*/
|
||||||
audio_url: string | null;
|
audio_url: string | null;
|
||||||
transcript: string | null;
|
transcript: string | null;
|
||||||
transcript_corrected: string | null;
|
transcript_corrected: string | null;
|
||||||
|
|||||||
@@ -12,9 +12,17 @@ MQTT_DYNSEC_ADMIN_PASS={{ vault_mqtt_dynsec_admin_pass }}
|
|||||||
|
|
||||||
# No GCP_CREDENTIALS_PATH — the VM uses Application Default Credentials
|
# No GCP_CREDENTIALS_PATH — the VM uses Application Default Credentials
|
||||||
# via the GCE metadata server. The Terraform IAM bindings grant the required roles.
|
# via the GCE metadata server. The Terraform IAM bindings grant the required roles.
|
||||||
|
# NOTE: because there is no service-account key file here, c2-core cannot mint
|
||||||
|
# GCS *signed* URLs. Call audio is therefore served through c2-core's own
|
||||||
|
# /media route (app/routers/media.py) rather than direct-from-bucket links.
|
||||||
FIRESTORE_DATABASE={{ vault_firestore_database }}
|
FIRESTORE_DATABASE={{ vault_firestore_database }}
|
||||||
GCS_BUCKET={{ vault_gcs_bucket }}
|
GCS_BUCKET={{ vault_gcs_bucket }}
|
||||||
|
|
||||||
|
# Absolute origin for call-audio playback links. The browser fetches <audio src>
|
||||||
|
# directly, so a relative path would resolve against the frontend origin
|
||||||
|
# (https://{{ domain }}) instead of the API's.
|
||||||
|
PUBLIC_API_URL=https://api.{{ domain }}
|
||||||
|
|
||||||
OPENAI_API_KEY={{ vault_openai_api_key }}
|
OPENAI_API_KEY={{ vault_openai_api_key }}
|
||||||
GOOGLE_MAPS_API_KEY={{ vault_google_maps_api_key }}
|
GOOGLE_MAPS_API_KEY={{ vault_google_maps_api_key }}
|
||||||
GEMINI_API_KEY={{ vault_gemini_api_key }}
|
GEMINI_API_KEY={{ vault_gemini_api_key }}
|
||||||
|
|||||||
Reference in New Issue
Block a user