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:
co-authored by
Claude Opus 5.5
parent
5983ae27be
commit
fa3eac3b28
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user