1use std::borrow::Cow;
21use std::num::{ParseFloatError, ParseIntError};
22use std::sync::Arc;
23
24#[derive(Debug)]
25struct InnerEngine {
26 client: reqwest::Client,
27 url: Cow<'static, str>,
28}
29
30impl Default for InnerEngine {
31 fn default() -> Self {
32 Self {
33 client: reqwest::Client::default(),
34 url: Cow::Borrowed("https://sunxdcc.com/deliver.php"),
35 }
36 }
37}
38
39#[derive(Debug, serde::Serialize)]
40struct QueryParams<'a> {
41 sterm: &'a str,
42 page: u8,
43}
44
45#[derive(Clone, Debug, Default)]
51pub struct Engine(Arc<InnerEngine>);
52
53impl Engine {
54 pub async fn search(&self, query: &str, page: u8) -> reqwest::Result<Vec<Entry>> {
69 let res = self
70 .0
71 .client
72 .get(self.0.url.as_ref())
73 .query(&QueryParams { sterm: query, page })
74 .send()
75 .await?;
76 res.error_for_status_ref()?;
77 let body: Response = res.json().await?;
78 Ok(body.into())
79 }
80}
81
82#[derive(Debug, serde::Deserialize)]
83struct Response {
84 botrec: Vec<String>,
85 network: Vec<String>,
86 bot: Vec<String>,
87 channel: Vec<String>,
88 packnum: Vec<String>,
89 gets: Vec<String>,
90 fsize: Vec<String>,
91 fname: Vec<String>,
92}
93
94impl Response {
95 fn into(self) -> Vec<Entry> {
96 self.fname
97 .into_iter()
98 .zip(self.fsize)
99 .zip(self.gets)
100 .zip(self.packnum)
101 .zip(self.channel)
102 .zip(self.network)
103 .zip(self.bot)
104 .zip(self.botrec)
105 .enumerate()
106 .filter_map(
107 |(
108 index,
109 (
110 ((((((fname, fsize), downloads), packnum), channel), network), bot_name),
111 bot_speed,
112 ),
113 )| {
114 Entry::try_decode(
115 fname, fsize, downloads, packnum, channel, network, bot_name, bot_speed,
116 )
117 .inspect_err(|err| {
118 tracing::debug!("unable to decode entry {index}: {err:?}");
119 })
120 .ok()
121 },
122 )
123 .collect::<Vec<_>>()
124 }
125}
126
127#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Deserialize, serde::Serialize)]
131pub struct Entry {
132 pub filename: String,
134 pub filesize: u64,
136 pub downloads: u64,
138 pub packnum: u64,
140 pub channel: String,
142 pub network: String,
144 pub bot_name: String,
146 pub bot_speed: u64,
148}
149
150impl Entry {
151 #[allow(clippy::too_many_arguments)]
159 fn try_decode(
160 fname: String,
161 fsize: String,
162 downloads: String,
163 packnum: String,
164 channel: String,
165 network: String,
166 bot_name: String,
167 bot_speed: String,
168 ) -> Result<Self, DecodingError> {
169 Ok(Self {
170 filename: fname,
171 filesize: decode_filesize(fsize)?,
172 downloads: decode_downloads(downloads)?,
173 packnum: decode_packnum(packnum)?,
174 channel,
175 network,
176 bot_name,
177 bot_speed: decode_speed(bot_speed)?,
178 })
179 }
180}
181
182#[derive(Clone, Debug, PartialEq, thiserror::Error)]
184pub enum DecodingError {
185 #[error("invalid {field:?} format, expected {expected:?}, received {value:?}")]
187 InvalidFormat {
188 field: &'static str,
189 value: String,
190 expected: &'static str,
191 },
192 #[error("invalid number in field {field:?}, expected a float, received {value:?}")]
194 InvalidFloat {
195 field: &'static str,
196 value: String,
197 error: ParseFloatError,
198 },
199 #[error("invalid number in field {field:?}, expected a int, received {value:?}")]
201 InvalidInt {
202 field: &'static str,
203 value: String,
204 error: ParseIntError,
205 },
206}
207
208const FILESIZE_FIELD: &str = "filesize";
209const FILESIZE_FORMAT: &str = "[1.1M]";
210
211fn decode_filesize(value: String) -> Result<u64, DecodingError> {
212 let Some(stripped) = value
213 .as_str()
214 .strip_prefix("[")
215 .and_then(|v| v.strip_suffix("]"))
216 else {
217 return Err(DecodingError::InvalidFormat {
218 field: FILESIZE_FIELD,
219 value,
220 expected: FILESIZE_FORMAT,
221 });
222 };
223 let Some(last_char) = stripped.chars().last() else {
224 return Err(DecodingError::InvalidFormat {
225 field: FILESIZE_FIELD,
226 value,
227 expected: FILESIZE_FORMAT,
228 });
229 };
230 let factor = match last_char.to_ascii_lowercase() {
231 'k' => 1024.0,
232 'm' => 1024.0 * 1024.0,
233 'g' => 1024.0 * 1024.0 * 1024.0,
234 't' => 1024.0 * 1024.0 * 1024.0 * 1024.0,
235 'p' => 1024.0 * 1024.0 * 1024.0 * 1024.0 * 1024.0,
236 '0'..='9' => 1.0,
237 _ => {
238 return Err(DecodingError::InvalidFormat {
239 field: FILESIZE_FIELD,
240 value,
241 expected: FILESIZE_FORMAT,
242 });
243 }
244 };
245 let number = if last_char.is_numeric() {
246 stripped
247 } else {
248 &stripped[..stripped.len() - 1]
249 };
250 let number = number
251 .trim()
252 .parse::<f64>()
253 .map_err(|error| DecodingError::InvalidFloat {
254 field: FILESIZE_FIELD,
255 value,
256 error,
257 })?;
258 let number = (number * factor) as u64;
259 Ok(number)
260}
261
262const GETS_FIELD: &str = "gets";
263const GETS_FORMAT: &str = "42x";
264
265fn decode_downloads(value: String) -> Result<u64, DecodingError> {
266 let Some(stripped) = value.strip_suffix('x') else {
267 return Err(DecodingError::InvalidFormat {
268 field: GETS_FIELD,
269 value,
270 expected: GETS_FORMAT,
271 });
272 };
273 stripped
274 .parse::<u64>()
275 .map_err(|error| DecodingError::InvalidInt {
276 field: GETS_FIELD,
277 value,
278 error,
279 })
280}
281
282const SPEED_FIELD: &str = "botrec";
283const SPEED_FORMAT: &str = "123.4kB/s";
284
285fn decode_speed(value: String) -> Result<u64, DecodingError> {
286 let number_size = value
287 .chars()
288 .take_while(|c| c.is_numeric() || *c == '.')
289 .count();
290 let Some((number, unit)) = value.split_at_checked(number_size) else {
291 return Err(DecodingError::InvalidFormat {
292 field: SPEED_FIELD,
293 value,
294 expected: SPEED_FORMAT,
295 });
296 };
297 let factor = match unit {
298 "B/s" => 1.0,
299 "kB/s" => 1024.0,
300 "MB/s" => 1024.0 * 1024.0,
301 "GB/s" => 1024.0 * 1024.0 * 1024.0,
302 "TB/s" => 1024.0 * 1024.0 * 1024.0 * 1024.0,
303 "PB/s" => 1024.0 * 1024.0 * 1024.0 * 1024.0 * 1024.0,
304 _ => {
305 return Err(DecodingError::InvalidFormat {
306 field: SPEED_FIELD,
307 value,
308 expected: SPEED_FORMAT,
309 });
310 }
311 };
312 let number = number
313 .parse::<f64>()
314 .map_err(|error| DecodingError::InvalidFloat {
315 field: SPEED_FIELD,
316 value,
317 error,
318 })?;
319 Ok((number * factor) as u64)
320}
321
322const PACKNUM_FIELD: &str = "packnum";
323const PACKNUM_FORMAT: &str = "#42";
324
325fn decode_packnum(value: String) -> Result<u64, DecodingError> {
326 let Some(number) = value.strip_prefix("#") else {
327 return Err(DecodingError::InvalidFormat {
328 field: PACKNUM_FIELD,
329 value,
330 expected: PACKNUM_FORMAT,
331 });
332 };
333 number
334 .parse::<u64>()
335 .map_err(|error| DecodingError::InvalidInt {
336 field: PACKNUM_FIELD,
337 value,
338 error,
339 })
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345
346 #[tokio::test]
347 async fn should_search_for_ubuntu() {
348 let mut src = mockito::Server::new_async().await;
349 let engine = Engine(Arc::new(InnerEngine {
350 client: Default::default(),
351 url: Cow::Owned(format!("{}/deliver.php", src.url())),
352 }));
353 let mock = src
354 .mock("GET", "/deliver.php?sterm=ubuntu&page=0")
355 .expect(1)
356 .with_body(include_str!("../resources/ubuntu.json"))
357 .create_async()
358 .await;
359 let list = engine.search("ubuntu", 0).await.unwrap();
360 assert_eq!(list.len(), 38);
361 assert!(list[0].filename.contains("Ubuntu"));
362 assert_eq!(list[0].filesize, 1503238553);
363 mock.assert_async().await;
364 }
365
366 #[test_case::test_case("[ 112]", 112; "without letter")]
367 #[test_case::test_case("[ 1k]", 1024; "simple kilo with dot")]
368 #[test_case::test_case("[ 1M]", 1024 * 1024; "simple mega without dot")]
369 #[test_case::test_case("[1.2M]", 1258291; "simple mega with dot")]
370 #[test_case::test_case("[1.2G]", 1288490188; "simple giga with dot")]
371 #[test_case::test_case("[1.2T]", 1319413953331; "simple tera with dot")]
372 fn should_decode_filesize(input: &str, expected: u64) {
373 assert_eq!(decode_filesize(input.into()).unwrap(), expected);
374 }
375
376 #[test_case::test_case("[ 12R]"; "invalid factor")]
377 fn shouldnt_decode_filesize(input: &str) {
378 assert!(decode_filesize(input.into()).is_err());
379 }
380
381 #[test_case::test_case("0x", 0; "zero")]
382 #[test_case::test_case("42x", 42; "2 digits")]
383 fn should_decode_downloads(input: &str, expected: u64) {
384 assert_eq!(decode_downloads(input.into()).unwrap(), expected);
385 }
386
387 #[test_case::test_case("12B/s", 12; "B/s")]
388 #[test_case::test_case("114012.3kB/s", 116748595; "kB/s")]
389 fn should_decode_speed(input: &str, expected: u64) {
390 assert_eq!(decode_speed(input.into()).unwrap(), expected);
391 }
392
393 #[test_case::test_case("#1", 1; "single digit")]
394 #[test_case::test_case("#1234", 1234; "multiple digits")]
395 fn should_decode_packnum(input: &str, expected: u64) {
396 assert_eq!(decode_packnum(input.into()).unwrap(), expected);
397 }
398}