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}