stargazer/upload/rdio.rs
1//! `SDRTrunk`-compatible Rdio API multipart upload client.
2//!
3//! This module implements the wire-level protocol used by the open-source
4//! `SDRTrunk` radio-recording tool to push completed call audio into a
5//! [Rdio Scanner](https://github.com/chuot/rdio-scanner) ingest server. The
6//! user's `sdrtrunk-rdio-api` Python service speaks the same protocol;
7//! stargazer impersonates `SDRTrunk` so that existing tooling does not need
8//! to grow a stargazer-specific code path.
9//!
10//! # Endpoint
11//!
12//! The caller configures a single base URL (default
13//! `http://rdio-api:8080/api/call-upload`). Every completed voice stream is
14//! `POST`ed to this URL as `multipart/form-data`.
15//!
16//! # Required request headers
17//!
18//! | Header | Value | Why |
19//! |---|---|---|
20//! | `User-Agent` | `sdrtrunk` | Some Rdio deployments gate ingest on the UA string — `sdrtrunk-rdio-api` accepts the literal `"sdrtrunk"` token. |
21//!
22//! The `Content-Type: multipart/form-data; boundary=...` header is set
23//! automatically by [`reqwest::multipart::Form`]; we do not build it by hand.
24//!
25//! # Form fields
26//!
27//! The server accepts the same field set that `SDRTrunk` emits when
28//! forwarding a trunked call. Text fields are UTF-8 strings; the `audio`
29//! part is a binary MP3 blob with an explicit filename and `audio/mpeg`
30//! content-type.
31//!
32//! | Field | Type | Example | Purpose |
33//! |---|---|---|---|
34//! | `key` | text | `stargazer-key` | API key configured via `rdio.api_key`. |
35//! | `system` | text | `10030` | Numeric system id — see [`crate::upload::compute_system_id`]. |
36//! | `systemLabel` | text | `REF030 (DPlus)` | Human-readable system name. |
37//! | `talkgroup` | text | `3` | Numeric talkgroup id (`A` → `1`, `B` → `2`, ...). |
38//! | `talkgroupLabel` | text | `Module C` | Human-readable talkgroup name. |
39//! | `talkgroupGroup` | text | `D-STAR` | Fixed group label. |
40//! | `source` | text | `W1AW` | Operator callsign. The user confirmed their Rdio fork accepts alphanumeric callsigns in this field (upstream expects a numeric radio id). |
41//! | `talkerAlias` | text | `W1AW / D75` | Callsign + optional suffix. |
42//! | `frequency` | text | `0` | Always zero — reflectors have no RF carrier. |
43//! | `dateTime` | text | `1760289000` | Unix seconds at start of transmission. |
44//! | `audio` | file | `audio/mpeg` MP3 bytes | The recorded call. Filename follows [`crate::upload::make_audio_name`]. |
45//! | `patches` | text | `[]` | Literal empty-array JSON — no patches. |
46//! | `talkgroupTag` | text | D-STAR text message, or `""` | Free-form 20-char slow-data message, passed through verbatim. |
47//!
48//! # Success signalling
49//!
50//! Rdio Scanner returns HTTP 200 with the literal string
51//! `"Call imported successfully."` embedded in the response body when ingest
52//! succeeds. This client reads the full body and checks for that substring
53//! via `str::contains`. Any other outcome — non-2xx status, missing marker,
54//! network error — becomes an [`UploadError`] variant so the caller can
55//! decide whether to retry.
56
57use reqwest::Client;
58use reqwest::multipart::{Form, Part};
59
60/// Marker substring that the Rdio Scanner API includes in a successful
61/// response body. Verified against the upstream source at
62/// <https://github.com/chuot/rdio-scanner>.
63const SUCCESS_MARKER: &str = "Call imported successfully.";
64
65/// MIME type for the uploaded MP3 blob.
66const AUDIO_MIME: &str = "audio/mpeg";
67
68/// `User-Agent` header value. Must be the literal `"sdrtrunk"` so that
69/// `sdrtrunk-rdio-api` routes the request through its ingest pipeline.
70const USER_AGENT: &str = "sdrtrunk";
71
72/// Errors returned by [`upload_stream`].
73///
74/// Every variant is non-fatal at the process level — the upload loop
75/// decides whether to retry or mark the row as permanently failed based on
76/// the error kind and the per-row attempt counter.
77#[derive(Debug, thiserror::Error)]
78pub(crate) enum UploadError {
79 /// The HTTP transport itself failed (DNS, TCP, TLS, read/write, or an
80 /// error surfaced by reqwest while reading the response body).
81 #[error("HTTP transport error: {0}")]
82 Http(#[from] reqwest::Error),
83
84 /// Constructing a [`reqwest::multipart::Part`] failed. In practice this
85 /// only fires for invalid filename characters; we keep it as a distinct
86 /// variant so the upload loop can log the offending field and move on.
87 #[error("multipart form build error: {0}")]
88 Build(String),
89
90 /// The server returned HTTP 2xx but the body did not contain the
91 /// `"Call imported successfully."` marker. This usually means the
92 /// server silently rejected the upload (e.g. duplicate call id or
93 /// malformed field) — retrying will not help.
94 #[error("unexpected response body: {0}")]
95 UnexpectedResponse(String),
96
97 /// The server returned a non-2xx HTTP status. The body is included for
98 /// operator diagnosis; very large bodies are truncated by the caller.
99 #[error("server error: status={status} body={body}")]
100 ServerError {
101 /// HTTP status code (4xx or 5xx).
102 status: u16,
103 /// Response body (may be truncated).
104 body: String,
105 },
106}
107
108/// Parameters for a single Rdio Scanner upload.
109///
110/// Grouped into a struct because the wire format has a dozen distinct
111/// fields and Clippy's `too_many_arguments` lint (denied in this crate)
112/// would otherwise force an `#[expect]` attribute.
113#[derive(Debug)]
114pub(crate) struct UploadFields<'a> {
115 /// API key sent in the `key` form field.
116 pub(crate) api_key: &'a str,
117 /// Numeric system id (see [`crate::upload::compute_system_id`]).
118 pub(crate) system: &'a str,
119 /// Human-readable system label, e.g. `"REF030 (DPlus)"`.
120 pub(crate) system_label: &'a str,
121 /// Numeric talkgroup id (`1`..`26`, from the module letter).
122 pub(crate) talkgroup: &'a str,
123 /// Human-readable talkgroup label, e.g. `"Module C"`.
124 pub(crate) talkgroup_label: &'a str,
125 /// Raw operator callsign.
126 pub(crate) source: &'a str,
127 /// Callsign + optional suffix, e.g. `"W1AW / D75"`.
128 pub(crate) talker_alias: &'a str,
129 /// Optional D-STAR slow-data text message (20 chars max).
130 pub(crate) talkgroup_tag: Option<&'a str>,
131 /// Unix seconds at the start of the transmission.
132 pub(crate) date_time: i64,
133 /// Filename for the `audio` file part (see
134 /// [`crate::upload::make_audio_name`]).
135 pub(crate) audio_name: &'a str,
136 /// MP3 bytes.
137 pub(crate) audio_mp3: Vec<u8>,
138}
139
140/// Uploads one completed voice stream to an `SDRTrunk`-compatible Rdio
141/// Scanner endpoint.
142///
143/// Builds the multipart form from `fields`, POSTs it to `endpoint` with
144/// `User-Agent: sdrtrunk`, and verifies the response body contains the
145/// `"Call imported successfully."` marker. Any deviation (network error,
146/// non-2xx status, missing marker) becomes an [`UploadError`].
147///
148/// # Errors
149///
150/// - [`UploadError::Http`] — transport/DNS/TLS/read failure.
151/// - [`UploadError::Build`] — multipart part construction failed (invalid
152/// filename).
153/// - [`UploadError::ServerError`] — non-2xx HTTP status.
154/// - [`UploadError::UnexpectedResponse`] — 2xx status but success marker
155/// absent from the body.
156pub(crate) async fn upload_stream(
157 client: &Client,
158 endpoint: &str,
159 fields: UploadFields<'_>,
160) -> Result<(), UploadError> {
161 // The text fields map 1:1 onto the columns documented in the module
162 // header. We build them first so the file part (which moves the MP3
163 // blob) can be appended last.
164 let talkgroup_tag = fields.talkgroup_tag.unwrap_or("");
165 let mut form = Form::new()
166 .text("key", fields.api_key.to_owned())
167 .text("system", fields.system.to_owned())
168 .text("systemLabel", fields.system_label.to_owned())
169 .text("talkgroup", fields.talkgroup.to_owned())
170 .text("talkgroupLabel", fields.talkgroup_label.to_owned())
171 .text("talkgroupGroup", "D-STAR")
172 .text("source", fields.source.to_owned())
173 .text("talkerAlias", fields.talker_alias.to_owned())
174 .text("frequency", "0")
175 .text("dateTime", fields.date_time.to_string())
176 .text("patches", "[]")
177 .text("talkgroupTag", talkgroup_tag.to_owned());
178
179 // `Part::bytes` never fails; only `mime_str` and `file_name` can fail
180 // at part-build time, and only for non-UTF-8 MIME strings or filenames
181 // with invalid header bytes. We surface both via UploadError::Build.
182 let audio_part = Part::bytes(fields.audio_mp3)
183 .file_name(fields.audio_name.to_owned())
184 .mime_str(AUDIO_MIME)
185 .map_err(|e| UploadError::Build(format!("audio part mime: {e}")))?;
186 form = form.part("audio", audio_part);
187
188 // POST with the sdrtrunk UA — see module-level docs for the rationale.
189 let response = client
190 .post(endpoint)
191 .header(reqwest::header::USER_AGENT, USER_AGENT)
192 .multipart(form)
193 .send()
194 .await?;
195
196 let status = response.status();
197 let body = response.text().await?;
198
199 if !status.is_success() {
200 // Truncate very large bodies (e.g. an HTML error page) to keep the
201 // error log readable; 512 bytes is plenty to show the server's
202 // human-readable failure message.
203 let truncated = truncate_body(&body, 512);
204 return Err(UploadError::ServerError {
205 status: status.as_u16(),
206 body: truncated,
207 });
208 }
209
210 if body.contains(SUCCESS_MARKER) {
211 Ok(())
212 } else {
213 Err(UploadError::UnexpectedResponse(truncate_body(&body, 512)))
214 }
215}
216
217/// Returns `body` truncated to at most `max_chars` UTF-8 characters, with
218/// an explicit `"... (truncated)"` suffix appended if truncation occurred.
219///
220/// Used when surfacing server response bodies in error variants — a
221/// server that returns a 5 MB HTML error page should not flood the logs.
222fn truncate_body(body: &str, max_chars: usize) -> String {
223 let char_count = body.chars().count();
224 if char_count <= max_chars {
225 body.to_owned()
226 } else {
227 let prefix: String = body.chars().take(max_chars).collect();
228 format!("{prefix}... (truncated, {char_count} chars total)")
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::{SUCCESS_MARKER, truncate_body};
235
236 #[test]
237 fn truncate_body_short_returns_unchanged() {
238 let body = "short body";
239 let result = truncate_body(body, 512);
240 assert_eq!(result, body, "body under the cap must be returned verbatim");
241 }
242
243 #[test]
244 fn truncate_body_long_adds_truncation_marker() {
245 let body = "a".repeat(1024);
246 let result = truncate_body(&body, 16);
247 assert!(
248 result.starts_with(&"a".repeat(16)),
249 "truncation must preserve the leading chars, got: {result}"
250 );
251 assert!(
252 result.contains("truncated"),
253 "truncated bodies must say so, got: {result}"
254 );
255 assert!(
256 result.contains("1024"),
257 "truncated bodies must include the original length, got: {result}"
258 );
259 }
260
261 #[test]
262 fn truncate_body_respects_utf8_char_boundaries() {
263 // Four-byte UTF-8 codepoint ("🎶" = U+1F3B6) repeated enough times
264 // that a naive byte-slice would split mid-codepoint.
265 let body = "🎶".repeat(100);
266 let result = truncate_body(&body, 10);
267 // Must not panic; must still be valid UTF-8.
268 assert!(
269 result.starts_with(&"🎶".repeat(10)),
270 "utf-8 truncation must keep whole codepoints, got: {result}"
271 );
272 }
273
274 #[test]
275 fn success_marker_is_the_sdrtrunk_literal() {
276 // Documented value from <https://github.com/chuot/rdio-scanner>;
277 // regressing this constant silently would break every upload.
278 assert_eq!(SUCCESS_MARKER, "Call imported successfully.");
279 }
280}