Skip to main content

xdcc_search/
sunxdcc.rs

1//! A lightweight client for querying [sunxdcc.com](https://sunxdcc.com) and parsing XDCC bot listings.
2//!
3//! This crate provides an asynchronous `Engine` to search for XDCC pack listings,
4//! returning decoded metadata as structured `Entry` items.
5//!
6//! # Example
7//!
8//! ```no_run
9//! # use xdcc_search::sunxdcc::{Engine, Entry};
10//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
11//! let engine = Engine::default();
12//! let results: Vec<Entry> = engine.search("ubuntu", 1).await?;
13//! for entry in results {
14//!     println!("Found pack: {} ({} bytes)", entry.filename, entry.filesize);
15//! }
16//! # Ok(())
17//! # }
18//! ```
19
20use 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/// The main entry point for querying the XDCC engine.
46///
47/// `Engine` is a lightweight, cloneable wrapper around an internal HTTP client.
48/// It provides a `search` method that sends a request to the XDCC listing service
49/// and returns a parsed list of results.
50#[derive(Clone, Debug, Default)]
51pub struct Engine(Arc<InnerEngine>);
52
53impl Engine {
54    /// Queries the XDCC engine for packs matching the given search term and page number.
55    ///
56    /// # Arguments
57    ///
58    /// * `query` - The search term (e.g., a keyword or filename).
59    /// * `page` - The page number to fetch (starting from 1).
60    ///
61    /// # Returns
62    ///
63    /// A `Vec<Entry>` containing the parsed pack information.
64    ///
65    /// # Errors
66    ///
67    /// Returns a `reqwest::Error` if the request fails or the response is malformed.
68    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/// A single XDCC listing entry returned from the search.
128///
129/// Contains all relevant metadata parsed from the server response.
130#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Deserialize, serde::Serialize)]
131pub struct Entry {
132    /// The name of the file being shared.
133    pub filename: String,
134    /// The size of the file in bytes.
135    pub filesize: u64,
136    /// Number of times the pack has been downloaded.
137    pub downloads: u64,
138    /// The XDCC pack number (used to request the pack).
139    pub packnum: u64,
140    /// The IRC channel where the bot is located.
141    pub channel: String,
142    /// The IRC network hosting the bot.
143    pub network: String,
144    /// The name of the bot sharing the file.
145    pub bot_name: String,
146    /// The reported upload speed of the bot, in bytes per second.
147    pub bot_speed: u64,
148}
149
150impl Entry {
151    /// Attempts to decode a set of string values from the server into a structured `Entry`.
152    ///
153    /// Each field is parsed individually, with validation and conversion applied.
154    ///
155    /// # Errors
156    ///
157    /// Returns `DecodingError` if any field fails to parse or is malformed.
158    #[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/// Represents an error that occurred while parsing or decoding a field from the response.
183#[derive(Clone, Debug, PartialEq, thiserror::Error)]
184pub enum DecodingError {
185    /// Field had an invalid format (e.g., missing prefix or suffix).
186    #[error("invalid {field:?} format, expected {expected:?}, received {value:?}")]
187    InvalidFormat {
188        field: &'static str,
189        value: String,
190        expected: &'static str,
191    },
192    /// Field could not be parsed as a float.
193    #[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    /// Field could not be parsed as an integer.
200    #[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}