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:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { CallRecord } from "@/lib/types";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
|
||||
@@ -37,11 +37,31 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
: call.incident_id ? [call.incident_id] : [];
|
||||
|
||||
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 hasBoth = !!(call.transcript && call.transcript_corrected);
|
||||
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() {
|
||||
setEditText(call.transcript_corrected ?? call.transcript ?? "");
|
||||
setEditing(true);
|
||||
@@ -89,7 +109,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs">
|
||||
{call.audio_url ? (
|
||||
{hasAudio ? (
|
||||
<span className="text-blue-400">▶</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">
|
||||
<td colSpan={7} className="px-6 py-3 space-y-2">
|
||||
{/* Audio player */}
|
||||
{call.audio_url && (
|
||||
<audio
|
||||
controls
|
||||
src={call.audio_url}
|
||||
className="w-full max-w-sm h-8"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{hasAudio && (
|
||||
audioError ? (
|
||||
<p className="text-xs text-red-400 font-mono">Could not load audio.</p>
|
||||
) : audioUrl ? (
|
||||
<audio
|
||||
controls
|
||||
src={audioUrl}
|
||||
className="w-full max-w-sm h-8"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xs text-gray-600 font-mono">Loading audio…</p>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
|
||||
@@ -93,6 +93,13 @@ export interface CallRecord {
|
||||
freq: number | null;
|
||||
started_at: string;
|
||||
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;
|
||||
transcript: string | null;
|
||||
transcript_corrected: string | null;
|
||||
|
||||
Reference in New Issue
Block a user