1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
//! `sunxdcc` is a small, unofficial Rust wrapper for the SunXDCC search engine's HTTP API.

#![deny(rustdoc::broken_intra_doc_links)]
#![deny(missing_docs)]
#![allow(clippy::redundant_field_names)]
#![forbid(unsafe_code)]

use itertools::izip;
use serde::Deserialize;
use thiserror::Error;
use url::Url;

const BASE_URL: &str = "https://sunxdcc.com/deliver.php";

/// Represents the errors that can occur when retrieving search results.
#[derive(Debug, Error)]
pub enum Error {
    /// An request error occurred.
    #[error("request error")]
    Request(#[from] reqwest::Error),
    /// A response contains malformed results.
    #[error("malformed response: {0}")]
    Malformed(String),
}

/// Represents the raw results from a single search request's response.
#[derive(Deserialize)]
struct RawResult {
    network: Vec<String>,
    channel: Vec<String>,
    bot: Vec<String>,
    fsize: Vec<String>,
    fname: Vec<String>,
    packnum: Vec<String>,
    gets: Vec<String>,
    botrec: Vec<String>,
}

impl RawResult {
    /// Are the contents of this `RawResult` consistent?
    ///
    /// Internally, a `RawResult` is a bunch of adjacent lists, and is
    /// "consistent" if and only if all lists are the same length.
    fn is_consistent(&self) -> bool {
        self.network.len() == self.channel.len()
            && self.channel.len() == self.bot.len()
            && self.channel.len() == self.fsize.len()
            && self.channel.len() == self.fname.len()
            && self.channel.len() == self.packnum.len()
            && self.channel.len() == self.gets.len()
            && self.channel.len() == self.botrec.len()
    }

    /// Consume this `RawResult`, constructing into `results`.
    fn consume(self, results: &mut Vec<SearchResult>) -> Result<(), Error> {
        if !self.is_consistent() {
            return Err(Error::Malformed("mismatch in adjacent list sizes".into()));
        }

        // Each result is inserted in reverse order, so that we can `pop` them later.
        for (network, channel, bot, fsize, fname, packnum, gets, botrec) in izip!(
            self.network.into_iter().rev(),
            self.channel.into_iter().rev(),
            self.bot.into_iter().rev(),
            self.fsize.into_iter().rev(),
            self.fname.into_iter().rev(),
            self.packnum.into_iter().rev(),
            self.gets.into_iter().rev(),
            self.botrec.into_iter().rev(),
        ) {
            let botrec = match botrec.as_str() {
                "Na" => None,
                _ => Some(botrec),
            };

            results.push(SearchResult {
                network: network,
                channel: channel,
                bot: bot,
                filesize: fsize,
                filename: fname,
                packet_number: packnum,
                download_count: gets,
                upload_speed: botrec,
            })
        }

        Ok(())
    }
}

/// Represents a single result from SunXDCC's search API.
#[derive(Debug)]
pub struct SearchResult {
    /// The IRC network that this result's bot is on.
    ///
    /// This is typically the fully-qualified domain name (e.g. `irc.foo.net`) or
    /// IP address for the record.
    pub network: String,

    /// The IRC channel that this result's bot is on.
    ///
    /// This is typically formatted as `"#channelname"`.
    pub channel: String,

    /// The IRC bot's name.
    pub bot: String,

    /// The size of the file.
    ///
    /// This is typically formatted as `"[XXXS]"`, e.g. `"[123M]"` for 123MB.
    pub filesize: String,

    /// The filename.
    pub filename: String,

    /// The packet number for this result.
    ///
    /// This is typically formatted as `"#XXX"`, e.g. `"#123"` for packet number 123.
    pub packet_number: String,

    /// The file's download count.
    ///
    /// This is typically formatted as `"Xx"`, e.g `"5x"` for 5 downloads.
    pub download_count: String,

    /// The serving IRC bot's maximum upload speed, if known.
    ///
    /// This is typically formatted as `"XXXX.YYkB/s"`, e.g. `"1000.25kB/s"`.
    pub upload_speed: Option<String>,
}

/// A stateful iteration container for search results.
#[derive(Debug)]
pub struct SearchResults<'search> {
    /// The client to use for all requests.
    client: reqwest::blocking::Client,
    /// The search query.
    query: &'search str,
    /// The current result page.
    current_page: usize,
    /// The current list of results.
    current_results: Vec<SearchResult>,
}

impl<'search> SearchResults<'search> {
    fn new(query: &'search str) -> Self {
        // Each query returns a maximum number of 50 results, so reserve at least
        // that many elements in our `current_results` buffer.
        Self {
            client: reqwest::blocking::Client::new(),
            query: query,
            current_page: 0,
            current_results: Vec::with_capacity(50),
        }
    }

    /// Refresh our internal state, fetching more results from the API if available.
    ///
    /// This function doesn't check whether the current results have been fully consumed;
    /// callers must take care to fully consume all current results to avoid silently
    ///
    fn refresh(&mut self) -> Result<(), Error> {
        self.current_results.clear();

        // Unwrap safety: BASE_URL is a correct URL and our parameters cannot cause an error.
        #[allow(clippy::unwrap_used)]
        let url = Url::parse_with_params(
            BASE_URL,
            &[
                ("sterm", self.query),
                ("page", &self.current_page.to_string()),
            ],
        )
        .unwrap();

        self.client
            .get(url)
            .send()?
            .json::<RawResult>()?
            .consume(&mut self.current_results)?;

        self.current_page += 1;

        Ok(())
    }
}

impl Iterator for SearchResults<'_> {
    type Item = Result<SearchResult, Error>;

    fn next(&mut self) -> Option<Self::Item> {
        // If we're just starting or we've exhausted our results, refresh our state.
        if self.current_page == 0 || self.current_results.is_empty() {
            if let Err(e) = self.refresh() {
                return Some(Err(e));
            }
        }

        // NOTE: This produces results in the correct order, despite the `pop`.
        // See the implementation of RawResult::consume.
        Ok(self.current_results.pop()).transpose()
    }
}

/// Search SunXDCC for the given `query`.
///
/// The returned `SearchResults` is an [`Iterator`](Iterator) over individual
/// [`SearchResult`](SearchResult) items.
///
/// ```no_run
/// # use sunxdcc;
/// for result in sunxdcc::search("the hitchhiker's guide to the galaxy") {
///     println!("{:?}", result.unwrap());
/// }
/// ```
pub fn search(query: &str) -> SearchResults {
    SearchResults::new(query)
}