modio/
download.rs

1//! Downloading mod files.
2use std::error::Error as StdError;
3use std::fmt;
4use std::path::Path;
5
6use bytes::Bytes;
7use futures_util::{SinkExt, Stream, StreamExt, TryFutureExt, TryStreamExt};
8use reqwest::{Method, Response, StatusCode};
9use tokio::fs::File as AsyncFile;
10use tokio::io::BufWriter;
11use tokio_util::codec::{BytesCodec, FramedWrite};
12use tracing::debug;
13
14use crate::error::{self, Result};
15use crate::types::files::File;
16use crate::types::id::{FileId, GameId, ModId};
17use crate::types::mods::Mod;
18use crate::Modio;
19
20/// A `Downloader` can be used to stream a mod file or save the file to a local file.
21/// Constructed with [`Modio::download`].
22pub struct Downloader(Response);
23
24impl Downloader {
25    pub(crate) async fn new(modio: Modio, action: DownloadAction) -> Result<Self> {
26        Ok(Self(request_file(modio, action).await?))
27    }
28
29    /// Save the mod file to a local file.
30    ///
31    /// # Example
32    /// ```no_run
33    /// # use modio::types::id::Id;
34    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
35    /// #     let modio = modio::Modio::new("api-key")?;
36    /// let action = modio::DownloadAction::Primary {
37    ///     game_id: Id::new(5),
38    ///     mod_id: Id::new(19),
39    /// };
40    ///
41    /// modio
42    ///     .download(action)
43    ///     .await?
44    ///     .save_to_file("mod.zip")
45    ///     .await?;
46    /// #     Ok(())
47    /// # }
48    /// ```
49    pub async fn save_to_file<P: AsRef<Path>>(self, file: P) -> Result<()> {
50        let out = AsyncFile::create(file).map_err(error::decode).await?;
51        let out = BufWriter::with_capacity(512 * 512, out);
52        let out = FramedWrite::new(out, BytesCodec::new());
53        let out = SinkExt::<Bytes>::sink_map_err(out, error::decode);
54        self.stream().forward(out).await
55    }
56
57    /// Get the full mod file as `Bytes`.
58    ///
59    /// # Example
60    /// ```no_run
61    /// # use modio::types::id::Id;
62    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
63    /// #     let modio = modio::Modio::new("api-key")?;
64    /// let action = modio::DownloadAction::Primary {
65    ///     game_id: Id::new(5),
66    ///     mod_id: Id::new(19),
67    /// };
68    ///
69    /// let bytes = modio.download(action).await?.bytes().await?;
70    /// #     Ok(())
71    /// # }
72    /// ```
73    pub async fn bytes(self) -> Result<Bytes> {
74        self.0.bytes().map_err(error::request).await
75    }
76
77    /// `Stream` of bytes of the mod file.
78    ///
79    /// # Example
80    /// ```no_run
81    /// use futures_util::TryStreamExt;
82    ///
83    /// # use modio::types::id::Id;
84    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
85    /// #     let modio = modio::Modio::new("api-key")?;
86    /// let action = modio::DownloadAction::Primary {
87    ///     game_id: Id::new(5),
88    ///     mod_id: Id::new(19),
89    /// };
90    ///
91    /// let mut st = Box::pin(modio.download(action).await?.stream());
92    /// while let Some(bytes) = st.try_next().await? {
93    ///     println!("Bytes: {:?}", bytes);
94    /// }
95    /// #     Ok(())
96    /// # }
97    /// ```
98    pub fn stream(self) -> impl Stream<Item = Result<Bytes>> {
99        self.0.bytes_stream().map_err(error::request)
100    }
101
102    /// Get the content length from the mod file response.
103    ///
104    /// # Example
105    /// ```no_run
106    /// # use modio::types::id::Id;
107    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
108    /// #     let modio = modio::Modio::new("api-key")?;
109    /// let action = modio::DownloadAction::Primary {
110    ///     game_id: Id::new(5),
111    ///     mod_id: Id::new(19),
112    /// };
113    ///
114    /// let content_length = modio
115    ///     .download(action)
116    ///     .await?
117    ///     .content_length()
118    ///     .expect("mod file response should have content length");
119    /// #     Ok(())
120    /// # }
121    /// ```
122    pub fn content_length(&self) -> Option<u64> {
123        self.0.content_length()
124    }
125}
126
127async fn request_file(modio: Modio, action: DownloadAction) -> Result<Response> {
128    let url = match action {
129        DownloadAction::Primary { game_id, mod_id } => {
130            let modref = modio.mod_(game_id, mod_id);
131            let m = modref
132                .get()
133                .map_err(|e| match e.status() {
134                    Some(StatusCode::NOT_FOUND) => {
135                        let source = Error::ModNotFound { game_id, mod_id };
136                        error::download(source)
137                    }
138                    _ => e,
139                })
140                .await?;
141            if let Some(file) = m.modfile {
142                file.download.binary_url
143            } else {
144                let source = Error::NoPrimaryFile { game_id, mod_id };
145                return Err(error::download(source));
146            }
147        }
148        DownloadAction::FileObj(file) => file.download.binary_url,
149        DownloadAction::File {
150            game_id,
151            mod_id,
152            file_id,
153        } => {
154            let fileref = modio.mod_(game_id, mod_id).file(file_id);
155            let file = fileref
156                .get()
157                .map_err(|e| match e.status() {
158                    Some(StatusCode::NOT_FOUND) => {
159                        let source = Error::FileNotFound {
160                            game_id,
161                            mod_id,
162                            file_id,
163                        };
164                        error::download(source)
165                    }
166                    _ => e,
167                })
168                .await?;
169            file.download.binary_url
170        }
171        DownloadAction::Version {
172            game_id,
173            mod_id,
174            version,
175            policy,
176        } => {
177            use crate::files::filters::Version;
178            use crate::filter::prelude::*;
179            use ResolvePolicy::*;
180
181            let filter = Version::eq(version.clone())
182                .order_by(DateAdded::desc())
183                .limit(2);
184
185            let files = modio.mod_(game_id, mod_id).files();
186            let mut list = files
187                .search(filter)
188                .first_page()
189                .map_err(|e| match e.status() {
190                    Some(StatusCode::NOT_FOUND) => {
191                        let source = Error::ModNotFound { game_id, mod_id };
192                        error::download(source)
193                    }
194                    _ => e,
195                })
196                .await?;
197
198            let (file, error) = match (list.len(), policy) {
199                (0, _) => (
200                    None,
201                    Some(Error::VersionNotFound {
202                        game_id,
203                        mod_id,
204                        version,
205                    }),
206                ),
207                (1, _) | (_, Latest) => (Some(list.remove(0)), None),
208                (_, Fail) => (
209                    None,
210                    Some(Error::MultipleFilesFound {
211                        game_id,
212                        mod_id,
213                        version,
214                    }),
215                ),
216            };
217
218            if let Some(file) = file {
219                file.download.binary_url
220            } else {
221                let source = error.expect("bug in previous match!");
222                return Err(error::download(source));
223            }
224        }
225    };
226
227    debug!("downloading file: {}", url);
228    modio
229        .inner
230        .client
231        .request(Method::GET, url)
232        .send()
233        .map_err(error::builder_or_request)
234        .await?
235        .error_for_status()
236        .map_err(error::request)
237}
238
239/// Defines the action that is performed for [`Modio::download`].
240#[derive(Debug)]
241pub enum DownloadAction {
242    /// Download the primary modfile of a mod.
243    Primary { game_id: GameId, mod_id: ModId },
244    /// Download a specific modfile of a mod.
245    File {
246        game_id: GameId,
247        mod_id: ModId,
248        file_id: FileId,
249    },
250    /// Download a specific modfile.
251    FileObj(Box<File>),
252    /// Download a specific version of a mod.
253    Version {
254        game_id: GameId,
255        mod_id: ModId,
256        version: String,
257        policy: ResolvePolicy,
258    },
259}
260
261/// Defines the policy for `DownloadAction::Version` when multiple files are found.
262#[derive(Debug)]
263pub enum ResolvePolicy {
264    /// Download the latest file.
265    Latest,
266    /// Return with [`Error::MultipleFilesFound`] as source error.
267    Fail,
268}
269
270/// The Errors that may occur when using [`Modio::download`].
271#[derive(Debug)]
272pub enum Error {
273    /// The mod has not found.
274    ModNotFound { game_id: GameId, mod_id: ModId },
275    /// The mod has no primary file.
276    NoPrimaryFile { game_id: GameId, mod_id: ModId },
277    /// The specific file of a mod was not found.
278    FileNotFound {
279        game_id: GameId,
280        mod_id: ModId,
281        file_id: FileId,
282    },
283    /// Multiple files for a given version were found and the policy was set to
284    /// [`ResolvePolicy::Fail`].
285    MultipleFilesFound {
286        game_id: GameId,
287        mod_id: ModId,
288        version: String,
289    },
290    /// No file for a given version was found.
291    VersionNotFound {
292        game_id: GameId,
293        mod_id: ModId,
294        version: String,
295    },
296}
297
298impl StdError for Error {}
299
300impl fmt::Display for Error {
301    fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
302        match self {
303            Error::ModNotFound { game_id, mod_id } => write!(
304                fmt,
305                "Mod {{id: {mod_id}, game_id: {game_id}}} not found.",
306            ),
307            Error::FileNotFound {
308                game_id,
309                mod_id,
310                file_id,
311            } => write!(
312                fmt,
313                "Mod {{id: {mod_id}, game_id: {game_id}}}: File {{ id: {file_id} }} not found.",
314            ),
315            Error::MultipleFilesFound {
316                game_id,
317                mod_id,
318                version,
319            } => write!(
320                fmt,
321                "Mod {{id: {mod_id}, game_id: {game_id}}}: Multiple files found for version '{version}'.",
322            ),
323            Error::NoPrimaryFile { game_id, mod_id } => write!(
324                fmt,
325                "Mod {{id: {mod_id}, game_id: {game_id}}} Mod has no primary file.",
326            ),
327            Error::VersionNotFound {
328                game_id,
329                mod_id,
330                version,
331            } => write!(
332                fmt,
333                "Mod {{id: {mod_id}, game_id: {game_id}}}: No file with version '{version}' found.",
334            ),
335        }
336    }
337}
338
339/// Convert `Mod` to [`DownloadAction::File`] or [`DownloadAction::Primary`] if `Mod::modfile` is `None`
340impl From<Mod> for DownloadAction {
341    fn from(m: Mod) -> DownloadAction {
342        if let Some(file) = m.modfile {
343            DownloadAction::from(file)
344        } else {
345            DownloadAction::Primary {
346                game_id: m.game_id,
347                mod_id: m.id,
348            }
349        }
350    }
351}
352
353/// Convert `File` to [`DownloadAction::FileObj`]
354impl From<File> for DownloadAction {
355    fn from(file: File) -> DownloadAction {
356        DownloadAction::FileObj(Box::new(file))
357    }
358}
359
360/// Convert `(GameId, ModId)` to [`DownloadAction::Primary`]
361impl From<(GameId, ModId)> for DownloadAction {
362    fn from((game_id, mod_id): (GameId, ModId)) -> DownloadAction {
363        DownloadAction::Primary { game_id, mod_id }
364    }
365}
366
367/// Convert `(GameId, ModId, FileId)` to [`DownloadAction::File`]
368impl From<(GameId, ModId, FileId)> for DownloadAction {
369    fn from((game_id, mod_id, file_id): (GameId, ModId, FileId)) -> DownloadAction {
370        DownloadAction::File {
371            game_id,
372            mod_id,
373            file_id,
374        }
375    }
376}
377
378/// Convert `(GameId, ModId, String)` to [`DownloadAction::Version`] with resolve policy
379/// set to `ResolvePolicy::Latest`
380impl From<(GameId, ModId, String)> for DownloadAction {
381    fn from((game_id, mod_id, version): (GameId, ModId, String)) -> DownloadAction {
382        DownloadAction::Version {
383            game_id,
384            mod_id,
385            version,
386            policy: ResolvePolicy::Latest,
387        }
388    }
389}
390
391/// Convert `(GameId, ModId, &'a str)` to [`DownloadAction::Version`] with resolve policy
392/// set to `ResolvePolicy::Latest`
393impl<'a> From<(GameId, ModId, &'a str)> for DownloadAction {
394    fn from((game_id, mod_id, version): (GameId, ModId, &'a str)) -> DownloadAction {
395        DownloadAction::Version {
396            game_id,
397            mod_id,
398            version: version.to_string(),
399            policy: ResolvePolicy::Latest,
400        }
401    }
402}