From fa3eac3b286a959bfe6e88e9178a044c843aa064 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Tue, 27 Oct 2026 20:38:30 -0400 Subject: [PATCH] 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 --- drb-edge-node/app/internal/call_recorder.py | 22 ++++++++++++++------- drb-edge-node/app/internal/upload_spool.py | 15 ++++++++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/drb-edge-node/app/internal/call_recorder.py b/drb-edge-node/app/internal/call_recorder.py index ff80551..8878be1 100644 --- a/drb-edge-node/app/internal/call_recorder.py +++ b/drb-edge-node/app/internal/call_recorder.py @@ -64,7 +64,7 @@ from collections import deque from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path -from typing import List, Optional, Tuple +from typing import Dict, List, Optional, Tuple from app.config import settings from app.internal import audio_trim, pcm, pulse, upload_spool @@ -335,10 +335,15 @@ class CallRecorder: # Lifecycle # ------------------------------------------------------------------ - async def start(self) -> None: - """Start the persistent capture. Call once from app lifespan.""" - self._stream_task = asyncio.create_task(self._capture_loop()) - logger.info("PulseAudio ring-buffer starting.") + async def start(self, capture: bool = True) -> None: + """Start the persistent capture. Call once from app lifespan. + + 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: self.spool.start() @@ -766,14 +771,15 @@ class CallRecorder: # ------------------------------------------------------------------ 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: # 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 # background retries get the full UPLOAD_TIMEOUT_SECONDS. 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( @@ -785,6 +791,7 @@ class CallRecorder: system_id: Optional[str] = None, audio_start_epoch: Optional[float] = None, audio_end_epoch: Optional[float] = None, + extra: Optional[Dict[str, str]] = None, ) -> Optional[str]: """ Upload once, inline. Returns the storage URL on success. @@ -802,6 +809,7 @@ class CallRecorder: meta = UploadMeta( 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, + extra=extra, ) result = await self._post_inline(file_path, meta) self.spool.last_result = {"call_id": call_id, "kind": result.kind, "status": result.status, diff --git a/drb-edge-node/app/internal/upload_spool.py b/drb-edge-node/app/internal/upload_spool.py index 351f68d..17ac3f4 100644 --- a/drb-edge-node/app/internal/upload_spool.py +++ b/drb-edge-node/app/internal/upload_spool.py @@ -71,6 +71,18 @@ class UploadMeta: system_id: Optional[str] = None audio_start_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 @@ -108,6 +120,9 @@ def _form(meta: UploadMeta) -> Dict[str, str]: form["audio_start_epoch"] = f"{meta.audio_start_epoch:.3f}" if meta.audio_end_epoch is not None: 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