upload path: extra form fields, MIME by file suffix, spool-only start (node-26#45)

UploadMeta.extra carries backend-specific /upload fields through the spool
sidecar; post_recording picks the MIME from the file suffix so a spooled
WAV is re-sent as audio/wav; call_recorder.start(capture=False) runs only
the spool, for a backend that records without PulseAudio.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-28 00:21:39 -04:00
co-authored by Claude Opus 5.5
parent 5983ae27be
commit fa3eac3b28
2 changed files with 30 additions and 7 deletions
+15 -7
View File
@@ -64,7 +64,7 @@ from collections import deque
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import List, Optional, Tuple from typing import Dict, List, Optional, Tuple
from app.config import settings from app.config import settings
from app.internal import audio_trim, pcm, pulse, upload_spool from app.internal import audio_trim, pcm, pulse, upload_spool
@@ -335,10 +335,15 @@ class CallRecorder:
# Lifecycle # Lifecycle
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def start(self) -> None: async def start(self, capture: bool = True) -> None:
"""Start the persistent capture. Call once from app lifespan.""" """Start the persistent capture. Call once from app lifespan.
self._stream_task = asyncio.create_task(self._capture_loop())
logger.info("PulseAudio ring-buffer starting.") capture=False starts only the upload spool: with the trunk-recorder
backend (node-26#45) TR does the recording, and a second recorder on
the same audio would upload every call twice."""
if capture:
self._stream_task = asyncio.create_task(self._capture_loop())
logger.info("PulseAudio ring-buffer starting.")
if settings.c2_url: if settings.c2_url:
self.spool.start() self.spool.start()
@@ -766,14 +771,15 @@ class CallRecorder:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def _post(self, file_path: Path, meta: UploadMeta) -> UploadResult: async def _post(self, file_path: Path, meta: UploadMeta) -> UploadResult:
return await upload_spool.post_recording(file_path, meta, AUDIO_MIME) return await upload_spool.post_recording(file_path, meta, upload_spool.mime_for(file_path, AUDIO_MIME))
async def _post_inline(self, file_path: Path, meta: UploadMeta) -> UploadResult: async def _post_inline(self, file_path: Path, meta: UploadMeta) -> UploadResult:
# metadata_watcher awaits on_call_end, so this attempt blocks the poll # metadata_watcher awaits on_call_end, so this attempt blocks the poll
# loop. With the spool behind it, it can afford a short timeout; the # loop. With the spool behind it, it can afford a short timeout; the
# background retries get the full UPLOAD_TIMEOUT_SECONDS. # background retries get the full UPLOAD_TIMEOUT_SECONDS.
return await upload_spool.post_recording( return await upload_spool.post_recording(
file_path, meta, AUDIO_MIME, timeout=settings.upload_inline_timeout_seconds, file_path, meta, upload_spool.mime_for(file_path, AUDIO_MIME),
timeout=settings.upload_inline_timeout_seconds,
) )
async def upload_recording( async def upload_recording(
@@ -785,6 +791,7 @@ class CallRecorder:
system_id: Optional[str] = None, system_id: Optional[str] = None,
audio_start_epoch: Optional[float] = None, audio_start_epoch: Optional[float] = None,
audio_end_epoch: Optional[float] = None, audio_end_epoch: Optional[float] = None,
extra: Optional[Dict[str, str]] = None,
) -> Optional[str]: ) -> Optional[str]:
""" """
Upload once, inline. Returns the storage URL on success. Upload once, inline. Returns the storage URL on success.
@@ -802,6 +809,7 @@ class CallRecorder:
meta = UploadMeta( meta = UploadMeta(
call_id=call_id, talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name, call_id=call_id, talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name,
system_id=system_id, audio_start_epoch=audio_start_epoch, audio_end_epoch=audio_end_epoch, system_id=system_id, audio_start_epoch=audio_start_epoch, audio_end_epoch=audio_end_epoch,
extra=extra,
) )
result = await self._post_inline(file_path, meta) result = await self._post_inline(file_path, meta)
self.spool.last_result = {"call_id": call_id, "kind": result.kind, "status": result.status, self.spool.last_result = {"call_id": call_id, "kind": result.kind, "status": result.status,
@@ -71,6 +71,18 @@ class UploadMeta:
system_id: Optional[str] = None system_id: Optional[str] = None
audio_start_epoch: Optional[float] = None audio_start_epoch: Optional[float] = None
audio_end_epoch: Optional[float] = None audio_end_epoch: Optional[float] = None
# Further form fields, sent as-is (Trunk Recorder calls carry units,
# emergency, encryption — node-26#45). Kept in the sidecar like the rest.
extra: Optional[Dict[str, str]] = None
# By extension, so a spooled file is re-sent with the type it was first sent
# with — the PulseAudio recorder writes FLAC, Trunk Recorder WAV.
AUDIO_MIME_BY_SUFFIX = {".flac": "audio/flac", ".wav": "audio/wav", ".mp3": "audio/mpeg", ".m4a": "audio/mp4"}
def mime_for(path: Path, default: str = "application/octet-stream") -> str:
return AUDIO_MIME_BY_SUFFIX.get(path.suffix.lower(), default)
@dataclass @dataclass
@@ -108,6 +120,9 @@ def _form(meta: UploadMeta) -> Dict[str, str]:
form["audio_start_epoch"] = f"{meta.audio_start_epoch:.3f}" form["audio_start_epoch"] = f"{meta.audio_start_epoch:.3f}"
if meta.audio_end_epoch is not None: if meta.audio_end_epoch is not None:
form["audio_end_epoch"] = f"{meta.audio_end_epoch:.3f}" form["audio_end_epoch"] = f"{meta.audio_end_epoch:.3f}"
for key, value in (meta.extra or {}).items():
if value is not None and key not in form:
form[key] = str(value)
return form return form