1use serde_json::Value;
5use url::Url;
6
7use crate::errors::AudDError;
8use crate::models::{CallbackEvent, StreamCallbackMatch, StreamCallbackNotification};
9
10#[must_use]
14pub fn derive_longpoll_category(api_token: &str, radio_id: i64) -> String {
15 let inner = format!("{:x}", md5::compute(api_token.as_bytes()));
16 let combined = format!("{inner}{radio_id}");
17 let outer = format!("{:x}", md5::compute(combined.as_bytes()));
18 outer[..9].to_string()
19}
20
21pub fn handle_callback(body: impl AsRef<[u8]>) -> Result<CallbackEvent, AudDError> {
37 let bytes = body.as_ref();
38 let value: Value = serde_json::from_slice(bytes).map_err(|e| AudDError::Serialization {
39 message: format!("callback body is not valid JSON: {e}"),
40 raw_text: String::from_utf8_lossy(bytes).into_owned(),
41 })?;
42 parse_callback(value)
43}
44
45pub fn parse_callback(body: Value) -> Result<CallbackEvent, AudDError> {
57 if let Some(notif_val) = body.get("notification").cloned() {
58 let mut notif: StreamCallbackNotification =
59 serde_json::from_value(notif_val).map_err(|e| AudDError::Serialization {
60 message: format!("callback notification: {e}"),
61 raw_text: body.to_string(),
62 })?;
63 notif.time = body.get("time").and_then(Value::as_i64);
64 notif.raw_response = body;
65 return Ok(CallbackEvent::Notification(notif));
66 }
67
68 if let Some(result_val) = body.get("result").cloned() {
69 let mut m: StreamCallbackMatch =
70 serde_json::from_value(result_val).map_err(|e| AudDError::Serialization {
71 message: format!("callback result: {e}"),
72 raw_text: body.to_string(),
73 })?;
74 m.raw_response = body;
75 return Ok(CallbackEvent::Match(m));
76 }
77
78 Err(AudDError::Serialization {
79 message: "callback body has neither `result` nor `notification`".into(),
80 raw_text: body.to_string(),
81 })
82}
83
84pub fn add_return_to_url(
97 url: &str,
98 return_metadata: Option<&[String]>,
99) -> Result<String, AudDError> {
100 let metadata = match return_metadata {
101 None => return Ok(url.to_string()),
102 Some(parts) if parts.is_empty() => return Ok(url.to_string()),
103 Some(parts) => parts.join(","),
104 };
105
106 let mut parsed = Url::parse(url)
107 .map_err(|e| AudDError::Source(format!("could not parse callback URL `{url}`: {e}")))?;
108 if parsed.query_pairs().any(|(k, _)| k == "return") {
109 return Err(duplicate_return_error());
110 }
111 parsed.query_pairs_mut().append_pair("return", &metadata);
112 Ok(parsed.to_string())
113}
114
115fn duplicate_return_error() -> AudDError {
116 use std::collections::HashMap;
117 AudDError::Api {
118 code: 0,
119 message: "URL already contains a `return` query parameter; pass return_metadata=None or remove the parameter from the URL — refusing to silently overwrite.".to_string(),
120 kind: crate::errors::ErrorKind::InvalidRequest,
121 http_status: 0,
122 request_id: None,
123 requested_params: HashMap::new(),
124 request_method: None,
125 branded_message: None,
126 raw_response: Value::Null,
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 #[test]
135 fn derive_category_is_nine_hex_chars() {
136 let c = derive_longpoll_category("test", 7);
137 assert_eq!(c.len(), 9);
138 assert!(c.chars().all(|c| c.is_ascii_hexdigit()));
139 }
140
141 #[test]
142 fn derive_category_is_deterministic() {
143 let a = derive_longpoll_category("abc", 1);
144 let b = derive_longpoll_category("abc", 1);
145 assert_eq!(a, b);
146 let c = derive_longpoll_category("abc", 2);
147 assert_ne!(a, c);
148 }
149
150 #[test]
151 fn add_return_appends() {
152 let out = add_return_to_url(
153 "https://example.com/cb",
154 Some(&["apple_music".into(), "spotify".into()]),
155 )
156 .unwrap();
157 assert!(
158 out.contains("return=apple_music%2Cspotify")
159 || out.contains("return=apple_music,spotify"),
160 "got {out}"
161 );
162 }
163
164 #[test]
165 fn add_return_merges_with_existing_query() {
166 let out =
167 add_return_to_url("https://example.com/cb?utm=x", Some(&["spotify".into()])).unwrap();
168 assert!(out.contains("utm=x"));
169 assert!(out.contains("return=spotify"));
170 }
171
172 #[test]
173 fn add_return_rejects_duplicate() {
174 let err = add_return_to_url(
175 "https://example.com/cb?return=apple_music",
176 Some(&["spotify".into()]),
177 )
178 .unwrap_err();
179 assert!(err.is_invalid_request());
180 }
181
182 #[test]
183 fn add_return_no_metadata_passthrough() {
184 let out = add_return_to_url("https://example.com/cb", None).unwrap();
185 assert_eq!(out, "https://example.com/cb");
186 }
187
188 #[test]
189 fn parse_callback_match() {
190 let body = serde_json::json!({
191 "status": "success",
192 "result": {
193 "radio_id": 7,
194 "results": [{"artist": "X", "title": "Y", "score": 100}]
195 }
196 });
197 let ev = parse_callback(body).unwrap();
198 let m = ev.as_match().expect("should be a match");
199 assert_eq!(m.radio_id, 7);
200 assert_eq!(m.song.title, "Y");
201 assert!(m.alternatives.is_empty());
202 }
203
204 #[test]
205 fn parse_callback_notification() {
206 let body = serde_json::json!({
207 "status": "-",
208 "notification": {
209 "radio_id": 3,
210 "stream_running": false,
211 "notification_code": 650,
212 "notification_message": "x"
213 },
214 "time": 1
215 });
216 let ev = parse_callback(body).unwrap();
217 let n = ev.as_notification().expect("should be a notification");
218 assert_eq!(n.radio_id, 3);
219 assert_eq!(n.notification_code, 650);
220 assert_eq!(n.time, Some(1));
221 }
222
223 #[test]
224 fn handle_callback_parses_raw_bytes() {
225 let bytes = br#"{"result":{"radio_id":1,"results":[{"artist":"X","title":"Y","score":50}]}}"#;
226 let ev = handle_callback(bytes.as_slice()).unwrap();
227 assert_eq!(ev.as_match().unwrap().song.score, 50);
228 }
229
230 #[test]
231 fn handle_callback_invalid_json_is_serialization_error() {
232 let bytes = b"not json";
233 let err = handle_callback(bytes.as_slice()).unwrap_err();
234 match err {
235 AudDError::Serialization { message, raw_text } => {
236 assert!(message.contains("not valid JSON"));
237 assert_eq!(raw_text, "not json");
238 }
239 other => panic!("expected Serialization, got {other:?}"),
240 }
241 }
242
243 #[test]
244 fn parse_callback_neither_shape_errors() {
245 let body = serde_json::json!({"status": "success"});
246 let err = parse_callback(body).unwrap_err();
247 assert!(matches!(err, AudDError::Serialization { .. }));
248 }
249}