1use std::collections::HashMap;
6use std::pin::Pin;
7
8use futures_core::Stream;
9use serde_json::Value;
10use tokio::sync::mpsc;
11use tokio::task::JoinHandle;
12
13use crate::client::{decode_or_raise, AudDInner};
14use crate::errors::{AudDError, ErrorKind};
15use crate::helpers::{add_return_to_url, derive_longpoll_category, parse_callback};
16use crate::http::HttpClient;
17use crate::models::{
18 CallbackEvent, Stream as StreamRow, StreamCallbackMatch, StreamCallbackNotification,
19};
20use crate::retry::{retry_async, RetryPolicy};
21
22const NO_CALLBACK_ERROR_CODE: i32 = 19;
28
29const HTTP_CLIENT_ERROR_FLOOR: u16 = 400;
30
31const LONGPOLL_TIMEOUT_MARGIN_SECS: u64 = 10;
34
35pub(crate) fn longpoll_request_timeout(poll_timeout_secs: i64) -> std::time::Duration {
39 let secs = u64::try_from(poll_timeout_secs).unwrap_or(0);
40 std::time::Duration::from_secs(secs + LONGPOLL_TIMEOUT_MARGIN_SECS)
41}
42
43fn indicates_no_callback_url(message: &str) -> bool {
47 let lower = message.to_lowercase();
48 lower.contains("internal") || lower.contains("callback")
49}
50
51const PREFLIGHT_NO_CALLBACK_HINT: &str =
52 "Longpoll won't deliver events because no callback URL is configured for this account. \
53Set one first via streams.set_callback_url(...) — `https://audd.tech/empty/` is fine if \
54you only want longpolling and don't need a real receiver. \
55To skip this check, pass skip_callback_check=true.";
56
57const CHANNEL_BUFFER: usize = 16;
60
61type BoxStream<T> = Pin<Box<dyn Stream<Item = T> + Send>>;
64
65#[derive(Debug, Clone)]
68pub struct LongpollOptions {
69 since_time: Option<i64>,
70 timeout: i64,
71 skip_callback_check: bool,
72}
73
74impl Default for LongpollOptions {
75 fn default() -> Self {
76 Self {
77 since_time: None,
78 timeout: 50,
79 skip_callback_check: false,
80 }
81 }
82}
83
84impl LongpollOptions {
85 #[must_use]
87 pub fn since_time(mut self, t: i64) -> Self {
88 self.since_time = Some(t);
89 self
90 }
91
92 #[must_use]
94 pub fn timeout(mut self, secs: i64) -> Self {
95 self.timeout = secs;
96 self
97 }
98
99 #[must_use]
101 pub fn skip_callback_check(mut self, skip: bool) -> Self {
102 self.skip_callback_check = skip;
103 self
104 }
105}
106
107pub struct LongpollPoll {
117 pub matches: BoxStream<StreamCallbackMatch>,
119 pub notifications: BoxStream<StreamCallbackNotification>,
121 pub errors: BoxStream<AudDError>,
123
124 shutdown: Option<mpsc::Sender<()>>,
125 join: Option<JoinHandle<()>>,
126}
127
128impl std::fmt::Debug for LongpollPoll {
129 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130 f.debug_struct("LongpollPoll").finish_non_exhaustive()
131 }
132}
133
134impl LongpollPoll {
135 pub async fn close(mut self) {
138 self.close_internal().await;
139 }
140
141 async fn close_internal(&mut self) {
142 self.shutdown.take();
144 if let Some(handle) = self.join.take() {
145 let _ = handle.await;
146 }
147 }
148}
149
150impl Drop for LongpollPoll {
151 fn drop(&mut self) {
152 self.shutdown.take();
156 if let Some(handle) = self.join.take() {
157 handle.abort();
158 }
159 }
160}
161
162pub struct Streams<'a> {
164 inner: &'a AudDInner,
165}
166
167impl<'a> Streams<'a> {
168 pub(crate) fn new(inner: &'a AudDInner) -> Self {
169 Self { inner }
170 }
171
172 pub async fn set_callback_url(
180 &self,
181 url: &str,
182 return_metadata: Option<&[String]>,
183 extra_parameters: Option<&HashMap<String, String>>,
184 ) -> Result<(), AudDError> {
185 let url = add_return_to_url(url, return_metadata)?;
186 let mut fields: Vec<(&str, String)> = Vec::new();
188 if let Some(extras) = extra_parameters {
189 for (k, v) in extras {
190 fields.push((k.as_str(), v.clone()));
191 }
192 }
193 fields.push(("url", url));
194 post_form(
195 self.inner,
196 "setCallbackUrl",
197 &format!("{}/setCallbackUrl/", self.inner.api_base),
198 &fields,
199 self.inner.mutating_policy(),
200 )
201 .await
202 .map(drop)
203 }
204
205 pub async fn get_callback_url(&self) -> Result<Option<String>, AudDError> {
214 let result = post_form(
215 self.inner,
216 "getCallbackUrl",
217 &format!("{}/getCallbackUrl/", self.inner.api_base),
218 &[],
219 self.inner.read_policy(),
220 )
221 .await?;
222 if result.is_null() {
223 return Ok(None);
224 }
225 Ok(Some(
226 result
227 .as_str()
228 .map_or_else(|| result.to_string(), str::to_string),
229 ))
230 }
231
232 pub async fn add(
243 &self,
244 url: &str,
245 radio_id: i64,
246 callbacks: Option<&str>,
247 extra_parameters: Option<&HashMap<String, String>>,
248 ) -> Result<(), AudDError> {
249 let mut fields: Vec<(&str, String)> = Vec::new();
251 if let Some(extras) = extra_parameters {
252 for (k, v) in extras {
253 fields.push((k.as_str(), v.clone()));
254 }
255 }
256 fields.push(("url", url.to_string()));
257 fields.push(("radio_id", radio_id.to_string()));
258 if let Some(cb) = callbacks {
259 fields.push(("callbacks", cb.to_string()));
260 }
261 post_form(
262 self.inner,
263 "addStream",
264 &format!("{}/addStream/", self.inner.api_base),
265 &fields,
266 self.inner.mutating_policy(),
267 )
268 .await
269 .map(drop)
270 }
271
272 pub async fn set_url(&self, radio_id: i64, url: &str) -> Result<(), AudDError> {
278 post_form(
279 self.inner,
280 "setStreamUrl",
281 &format!("{}/setStreamUrl/", self.inner.api_base),
282 &[("radio_id", radio_id.to_string()), ("url", url.to_string())],
283 self.inner.mutating_policy(),
284 )
285 .await
286 .map(drop)
287 }
288
289 pub async fn delete(&self, radio_id: i64) -> Result<(), AudDError> {
295 post_form(
296 self.inner,
297 "deleteStream",
298 &format!("{}/deleteStream/", self.inner.api_base),
299 &[("radio_id", radio_id.to_string())],
300 self.inner.mutating_policy(),
301 )
302 .await
303 .map(drop)
304 }
305
306 pub async fn list(&self) -> Result<Vec<StreamRow>, AudDError> {
312 let result = post_form(
313 self.inner,
314 "getStreams",
315 &format!("{}/getStreams/", self.inner.api_base),
316 &[],
317 self.inner.read_policy(),
318 )
319 .await?;
320 if result.is_null() {
321 return Ok(Vec::new());
322 }
323 let v: Vec<StreamRow> =
324 serde_json::from_value(result.clone()).map_err(|e| AudDError::Serialization {
325 message: format!("could not parse getStreams result: {e}"),
326 raw_text: result.to_string(),
327 })?;
328 Ok(v)
329 }
330
331 #[must_use]
335 pub fn derive_longpoll_category(&self, radio_id: i64) -> String {
336 derive_longpoll_category(&self.inner.api_token(), radio_id)
337 }
338
339 pub fn parse_callback(&self, body: Value) -> Result<CallbackEvent, AudDError> {
346 parse_callback(body)
347 }
348
349 pub async fn longpoll(
368 &self,
369 category: &str,
370 opts: LongpollOptions,
371 ) -> Result<LongpollPoll, AudDError> {
372 if !opts.skip_callback_check {
373 self.preflight_callback().await?;
374 }
375 Ok(spawn_longpoll(LongpollDriver::Authenticated {
376 http: self.inner.http.clone(),
377 url: format!("{}/longpoll/", self.inner.api_base),
378 policy: self.inner.read_policy(),
379 category: category.to_string(),
380 opts,
381 }))
382 }
383
384 pub async fn longpoll_by_radio_id(
395 &self,
396 radio_id: i64,
397 opts: LongpollOptions,
398 ) -> Result<LongpollPoll, AudDError> {
399 let category = self.derive_longpoll_category(radio_id);
400 self.longpoll(&category, opts).await
401 }
402
403 async fn preflight_callback(&self) -> Result<(), AudDError> {
404 match self.get_callback_url().await {
405 Ok(_) => Ok(()),
406 Err(AudDError::Api {
407 code: NO_CALLBACK_ERROR_CODE,
408 message,
409 http_status,
410 request_id,
411 ..
412 }) if indicates_no_callback_url(&message) => Err(AudDError::Api {
413 code: 0,
414 message: PREFLIGHT_NO_CALLBACK_HINT.to_string(),
415 kind: ErrorKind::InvalidRequest,
416 http_status,
417 request_id,
418 requested_params: std::collections::HashMap::new(),
419 request_method: None,
420 branded_message: None,
421 raw_response: Value::Null,
422 }),
423 Err(other) => Err(other),
426 }
427 }
428}
429
430pub(crate) enum LongpollDriver {
433 Authenticated {
434 http: HttpClient,
435 url: String,
436 policy: RetryPolicy,
437 category: String,
438 opts: LongpollOptions,
439 },
440 Tokenless {
441 http: crate::http::BareHttpClient,
442 url: String,
443 policy: RetryPolicy,
444 category: String,
445 since_time: Option<i64>,
446 timeout: i64,
447 },
448}
449
450impl LongpollDriver {
451 fn category(&self) -> &str {
452 match self {
453 Self::Authenticated { category, .. } | Self::Tokenless { category, .. } => category,
454 }
455 }
456
457 fn timeout(&self) -> i64 {
458 match self {
459 Self::Authenticated { opts, .. } => opts.timeout,
460 Self::Tokenless { timeout, .. } => *timeout,
461 }
462 }
463
464 fn since_time(&self) -> Option<i64> {
465 match self {
466 Self::Authenticated { opts, .. } => opts.since_time,
467 Self::Tokenless { since_time, .. } => *since_time,
468 }
469 }
470
471 async fn fetch(
472 &self,
473 params: &[(&str, String)],
474 ) -> Result<crate::http::HttpResponse, AudDError> {
475 let request_timeout = longpoll_request_timeout(self.timeout());
479 match self {
480 Self::Authenticated {
481 http, url, policy, ..
482 } => {
483 let url = url.clone();
484 let policy = *policy;
485 let http = http.clone();
486 let params: Vec<(&str, String)> =
487 params.iter().map(|(k, v)| (*k, v.clone())).collect();
488 retry_async(
489 || {
490 let http = http.clone();
491 let url = url.clone();
492 let params = params.clone();
493 async move { http.get(&url, ¶ms, Some(request_timeout)).await }
494 },
495 policy,
496 )
497 .await
498 }
499 Self::Tokenless {
500 http, url, policy, ..
501 } => {
502 let url = url.clone();
503 let policy = *policy;
504 let http = http.clone();
505 let params: Vec<(&str, String)> =
506 params.iter().map(|(k, v)| (*k, v.clone())).collect();
507 retry_async(
508 || {
509 let http = http.clone();
510 let url = url.clone();
511 let params = params.clone();
512 async move { http.get(&url, ¶ms, Some(request_timeout)).await }
513 },
514 policy,
515 )
516 .await
517 }
518 }
519 }
520}
521
522pub(crate) fn spawn_longpoll(driver: LongpollDriver) -> LongpollPoll {
524 let (match_tx, match_rx) = mpsc::channel::<StreamCallbackMatch>(CHANNEL_BUFFER);
525 let (notif_tx, notif_rx) = mpsc::channel::<StreamCallbackNotification>(CHANNEL_BUFFER);
526 let (err_tx, err_rx) = mpsc::channel::<AudDError>(1);
527 let (shutdown_tx, shutdown_rx) = mpsc::channel::<()>(1);
528
529 let join = tokio::spawn(run_longpoll(
530 driver,
531 match_tx,
532 notif_tx,
533 err_tx,
534 shutdown_rx,
535 ));
536
537 LongpollPoll {
538 matches: Box::pin(channel_stream(match_rx)),
539 notifications: Box::pin(channel_stream(notif_rx)),
540 errors: Box::pin(channel_stream(err_rx)),
541 shutdown: Some(shutdown_tx),
542 join: Some(join),
543 }
544}
545
546fn channel_stream<T: Send + 'static>(mut rx: mpsc::Receiver<T>) -> impl Stream<Item = T> + Send {
547 async_stream::stream! {
548 while let Some(item) = rx.recv().await {
549 yield item;
550 }
551 }
552}
553
554async fn run_longpoll(
558 driver: LongpollDriver,
559 match_tx: mpsc::Sender<StreamCallbackMatch>,
560 notif_tx: mpsc::Sender<StreamCallbackNotification>,
561 err_tx: mpsc::Sender<AudDError>,
562 mut shutdown_rx: mpsc::Receiver<()>,
563) {
564 let mut cur_since = driver.since_time();
565 let timeout_secs = driver.timeout().to_string();
566 let category = driver.category().to_string();
567
568 loop {
569 let mut params: Vec<(&str, String)> = vec![
571 ("category", category.clone()),
572 ("timeout", timeout_secs.clone()),
573 ];
574 if let Some(t) = cur_since {
575 params.push(("since_time", t.to_string()));
576 }
577
578 let resp = tokio::select! {
581 biased;
582 _ = shutdown_rx.recv() => return,
583 r = driver.fetch(¶ms) => r,
584 };
585
586 let resp = match resp {
587 Ok(r) => r,
588 Err(e) => {
589 let _ = err_tx.send(e).await;
590 return;
591 }
592 };
593
594 if resp.http_status >= HTTP_CLIENT_ERROR_FLOOR {
596 let _ = err_tx
597 .send(AudDError::Server {
598 http_status: resp.http_status,
599 message: format!("Longpoll endpoint returned HTTP {}", resp.http_status),
600 request_id: resp.request_id,
601 raw_response: resp.raw_text,
602 })
603 .await;
604 return;
605 }
606
607 let Some(body) = resp.json_body else {
608 let _ = err_tx
609 .send(AudDError::Serialization {
610 message: "Longpoll response was not a JSON object".into(),
611 raw_text: resp.raw_text,
612 })
613 .await;
614 return;
615 };
616
617 if is_longpoll_keepalive(&body) {
619 if let Some(ts) = body.get("timestamp").and_then(Value::as_i64) {
620 cur_since = Some(ts);
621 }
622 continue;
623 }
624
625 if let Some(ts) = body.get("timestamp").and_then(Value::as_i64) {
628 cur_since = Some(ts);
629 }
630
631 match parse_callback(body) {
632 Ok(CallbackEvent::Match(m)) => {
633 tokio::select! {
634 biased;
635 _ = shutdown_rx.recv() => return,
636 res = match_tx.send(m) => {
637 if res.is_err() { return; }
638 }
639 }
640 }
641 Ok(CallbackEvent::Notification(n)) => {
642 tokio::select! {
643 biased;
644 _ = shutdown_rx.recv() => return,
645 res = notif_tx.send(n) => {
646 if res.is_err() { return; }
647 }
648 }
649 }
650 Err(e) => {
651 let _ = err_tx.send(e).await;
652 return;
653 }
654 }
655 }
656}
657
658pub(crate) fn is_longpoll_keepalive(body: &Value) -> bool {
663 let Some(obj) = body.as_object() else {
664 return false;
665 };
666 if obj.contains_key("result") || obj.contains_key("notification") {
667 return false;
668 }
669 obj.contains_key("timeout")
670}
671
672async fn post_form(
676 inner: &AudDInner,
677 method: &str,
678 url: &str,
679 fields: &[(&str, String)],
680 policy: RetryPolicy,
681) -> Result<Value, AudDError> {
682 let http = &inner.http;
683 let url = url.to_string();
684 let fields: Vec<(&str, String)> = fields.iter().map(|(k, v)| (*k, v.clone())).collect();
685 let started = inner.emit_request(method, &url);
686 let resp = retry_async(
687 || {
688 let http = http.clone();
689 let url = url.clone();
690 let fields = fields.clone();
691 async move { http.post_form(&url, &fields, None, None).await }
692 },
693 policy,
694 )
695 .await;
696 let resp = match resp {
697 Ok(r) => r,
698 Err(e) => {
699 inner.emit_exception(method, &url, started, &e);
700 return Err(e);
701 }
702 };
703 inner.emit_response(method, &url, started, &resp);
704 let body = decode_or_raise(resp, false)?;
705 Ok(body.get("result").cloned().unwrap_or(Value::Null))
706}
707
708#[cfg(test)]
709mod tests {
710 use super::*;
711
712 #[test]
713 fn longpoll_options_default() {
714 let o = LongpollOptions::default();
715 assert_eq!(o.timeout, 50);
716 assert!(!o.skip_callback_check);
717 }
718
719 #[test]
720 fn longpoll_options_chain() {
721 let o = LongpollOptions::default()
722 .timeout(30)
723 .since_time(123)
724 .skip_callback_check(true);
725 assert_eq!(o.timeout, 30);
726 assert_eq!(o.since_time, Some(123));
727 assert!(o.skip_callback_check);
728 }
729
730 #[test]
731 fn longpoll_request_timeout_sizes_above_poll_timeout() {
732 assert_eq!(
733 longpoll_request_timeout(50),
734 std::time::Duration::from_secs(60)
735 );
736 assert_eq!(
737 longpoll_request_timeout(300),
738 std::time::Duration::from_secs(310)
739 );
740 assert_eq!(
742 longpoll_request_timeout(-1),
743 std::time::Duration::from_secs(10)
744 );
745 }
746
747 #[test]
748 fn no_callback_url_signal_detection() {
749 assert!(indicates_no_callback_url("Internal error"));
750 assert!(indicates_no_callback_url("no callback url set"));
751 assert!(!indicates_no_callback_url(
752 "Scheduled maintenance, try again later"
753 ));
754 assert!(!indicates_no_callback_url("request blocked"));
755 }
756
757 #[test]
758 fn keepalive_detection() {
759 let kp = serde_json::json!({"timeout": "no events before timeout", "timestamp": 1});
760 assert!(is_longpoll_keepalive(&kp));
761
762 let with_result = serde_json::json!({
763 "result": {"radio_id": 1, "results": []},
764 "timeout": "no events"
765 });
766 assert!(!is_longpoll_keepalive(&with_result));
767
768 let with_notif = serde_json::json!({
769 "notification": {"radio_id": 1},
770 "timeout": "x"
771 });
772 assert!(!is_longpoll_keepalive(&with_notif));
773
774 let no_timeout = serde_json::json!({"timestamp": 1});
775 assert!(!is_longpoll_keepalive(&no_timeout));
776
777 let not_object = serde_json::json!([1, 2, 3]);
778 assert!(!is_longpoll_keepalive(¬_object));
779 }
780}