[fix](trx-frontend-http): accept remote on audio endpoint

Parse the renamed `remote` query parameter on `/audio` while keeping
`rig_id` as a compatibility alias, and use per-rig stream info for
rig-scoped audio subscriptions.

Add tests covering both query names.

Co-authored-by: OpenAI Codex <noreply@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-23 22:31:04 +01:00
parent b8ce05d41e
commit 7d76606927
@@ -475,7 +475,8 @@ pub fn start_decode_history_collector(context: Arc<FrontendRuntimeContext>) {
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct AudioQuery { pub struct AudioQuery {
pub channel_id: Option<Uuid>, pub channel_id: Option<Uuid>,
pub rig_id: Option<String>, #[serde(alias = "rig_id")]
pub remote: Option<String>,
} }
#[get("/audio")] #[get("/audio")]
@@ -488,9 +489,6 @@ pub async fn audio_ws(
let Some(tx_sender) = context.audio_tx.as_ref().cloned() else { let Some(tx_sender) = context.audio_tx.as_ref().cloned() else {
return Ok(HttpResponse::NotFound().body("audio not enabled")); return Ok(HttpResponse::NotFound().body("audio not enabled"));
}; };
let Some(mut info_rx) = context.audio_info.as_ref().cloned() else {
return Ok(HttpResponse::NotFound().body("audio not enabled"));
};
// Plain GET probe (no WebSocket upgrade) - return 204 to signal audio is available. // Plain GET probe (no WebSocket upgrade) - return 204 to signal audio is available.
if !req.headers().contains_key("upgrade") { if !req.headers().contains_key("upgrade") {
@@ -501,9 +499,15 @@ pub async fn audio_ws(
// The entry is created asynchronously when AUDIO_MSG_VCHAN_ALLOCATED arrives // The entry is created asynchronously when AUDIO_MSG_VCHAN_ALLOCATED arrives
// from the server, which may lag the HTTP allocation by up to ~100 ms. // from the server, which may lag the HTTP allocation by up to ~100 ms.
// Poll for up to 2 s so a tight JS timer doesn't race and get a 404. // Poll for up to 2 s so a tight JS timer doesn't race and get a 404.
let rx_sub: broadcast::Receiver<Bytes> = if let Some(ch_id) = query.channel_id { let (rx_sub, mut info_rx): (
broadcast::Receiver<Bytes>,
tokio::sync::watch::Receiver<Option<trx_core::audio::AudioStreamInfo>>,
) = if let Some(ch_id) = query.channel_id {
let Some(info_rx) = context.audio_info.as_ref().cloned() else {
return Ok(HttpResponse::NotFound().body("audio not enabled"));
};
let deadline = Instant::now() + Duration::from_secs(2); let deadline = Instant::now() + Duration::from_secs(2);
loop { let rx_sub = loop {
match context.vchan_audio.read() { match context.vchan_audio.read() {
Ok(map) => { Ok(map) => {
if let Some(tx) = map.get(&ch_id) { if let Some(tx) = map.get(&ch_id) {
@@ -516,29 +520,37 @@ pub async fn audio_ws(
return Ok(HttpResponse::NotFound().body("channel not found")); return Ok(HttpResponse::NotFound().body("channel not found"));
} }
tokio::time::sleep(Duration::from_millis(50)).await; tokio::time::sleep(Duration::from_millis(50)).await;
} };
} else if let Some(ref rig_id) = query.rig_id { (rx_sub, info_rx)
} else if let Some(ref remote) = query.remote {
// Per-rig audio: subscribe to the specific rig's broadcast. // Per-rig audio: subscribe to the specific rig's broadcast.
// Do NOT fall back to global — that would silently deliver the wrong // Do NOT fall back to global — that would silently deliver the wrong
// rig's audio. Wait briefly for the per-rig channel to appear (it is // rig's audio. Wait briefly for the per-rig channel to appear (it is
// lazily created by the audio relay sync task every 500ms). // lazily created by the audio relay sync task every 500ms).
let deadline = Instant::now() + Duration::from_secs(3); let deadline = Instant::now() + Duration::from_secs(3);
loop { let (rx_sub, info_rx) = loop {
if let Some(rx) = context.rig_audio_subscribe(rig_id) { if let (Some(rx), Some(info_rx)) = (
break rx; context.rig_audio_subscribe(remote),
context.rig_audio_info_rx(remote),
) {
break (rx, info_rx);
} }
if Instant::now() >= deadline { if Instant::now() >= deadline {
return Ok( return Ok(
HttpResponse::NotFound().body(format!("audio not available for rig {rig_id}")) HttpResponse::NotFound().body(format!("audio not available for rig {remote}"))
); );
} }
tokio::time::sleep(Duration::from_millis(100)).await; tokio::time::sleep(Duration::from_millis(100)).await;
} };
(rx_sub, info_rx)
} else { } else {
let Some(info_rx) = context.audio_info.as_ref().cloned() else {
return Ok(HttpResponse::NotFound().body("audio not enabled"));
};
let Some(rx) = context.audio_rx.as_ref() else { let Some(rx) = context.audio_rx.as_ref() else {
return Ok(HttpResponse::NotFound().body("audio not enabled")); return Ok(HttpResponse::NotFound().body("audio not enabled"));
}; };
rx.subscribe() (rx.subscribe(), info_rx)
}; };
let mut rx_sub = rx_sub; let mut rx_sub = rx_sub;
@@ -621,3 +633,22 @@ pub async fn audio_ws(
Ok(response) Ok(response)
} }
#[cfg(test)]
mod tests {
use super::AudioQuery;
#[test]
fn audio_query_accepts_remote() {
let query: AudioQuery =
serde_json::from_str(r#"{"remote":"lidzbark-vhf"}"#).expect("query parse");
assert_eq!(query.remote.as_deref(), Some("lidzbark-vhf"));
}
#[test]
fn audio_query_accepts_legacy_rig_id_alias() {
let query: AudioQuery =
serde_json::from_str(r#"{"rig_id":"gdansk"}"#).expect("query parse");
assert_eq!(query.remote.as_deref(), Some("gdansk"));
}
}