Skip to main content

hexomc_lib/mods/
modrinth.rs

1use serde::{Deserialize, Serialize};
2
3use crate::error::Result;
4use crate::mods::detector::ModLoader;
5
6const MR_BASE: &str = "https://api.modrinth.com/v2";
7const USER_AGENT: &str = concat!("hexomc-lib/", env!("CARGO_PKG_VERSION"));
8
9pub struct ModrinthClient {
10    client: reqwest::Client,
11}
12
13impl ModrinthClient {
14    pub fn new() -> Self {
15        let client = reqwest::Client::builder()
16            .user_agent(USER_AGENT)
17            .build()
18            .unwrap_or_default();
19        Self { client }
20    }
21}
22
23impl Default for ModrinthClient {
24    fn default() -> Self {
25        Self::new()
26    }
27}
28
29#[derive(Debug, Deserialize, Serialize, Clone)]
30pub struct MrProject {
31    #[serde(alias = "project_id")]
32    pub id: String,
33    pub slug: String,
34    pub title: String,
35    pub description: String,
36    pub downloads: u64,
37}
38
39#[derive(Debug, Deserialize, Serialize, Clone)]
40pub struct MrVersion {
41    pub id: String,
42    pub project_id: String,
43    pub name: String,
44    pub version_number: String,
45    pub game_versions: Vec<String>,
46    pub loaders: Vec<String>,
47    pub date_published: String,
48    pub files: Vec<MrFile>,
49}
50
51#[derive(Debug, Deserialize, Serialize, Clone)]
52pub struct MrFile {
53    pub hashes: MrHashes,
54    pub url: String,
55    pub filename: String,
56    pub primary: bool,
57    pub size: u64,
58}
59
60#[derive(Debug, Deserialize, Serialize, Clone)]
61pub struct MrHashes {
62    pub sha1: String,
63    pub sha512: String,
64}
65
66impl MrVersion {
67    /// The file marked primary, or the first file if none is.
68    pub fn primary_file(&self) -> Option<&MrFile> {
69        self.files
70            .iter()
71            .find(|f| f.primary)
72            .or_else(|| self.files.first())
73    }
74
75    pub fn sha1(&self) -> Option<&str> {
76        self.primary_file().map(|f| f.hashes.sha1.as_str())
77    }
78
79    pub fn download_url(&self) -> Option<&str> {
80        self.primary_file().map(|f| f.url.as_str())
81    }
82
83    pub fn file_name(&self) -> Option<&str> {
84        self.primary_file().map(|f| f.filename.as_str())
85    }
86}
87
88/// Modrinth's loader tag. Empty for `Unknown` (no loader facet applied).
89fn loader_tag(loader: &ModLoader) -> &'static str {
90    match loader {
91        ModLoader::Forge => "forge",
92        ModLoader::Fabric => "fabric",
93        ModLoader::Quilt => "quilt",
94        ModLoader::NeoForge => "neoforge",
95        ModLoader::Unknown => "",
96    }
97}
98
99impl ModrinthClient {
100    /// Search for mods.
101    pub async fn search_mod(&self, name: &str, loader: &ModLoader) -> Result<Vec<MrProject>> {
102        #[derive(Deserialize)]
103        struct Resp {
104            hits: Vec<MrProject>,
105        }
106
107        let tag = loader_tag(loader);
108        let facets = if tag.is_empty() {
109            "[[\"project_type:mod\"]]".to_string()
110        } else {
111            format!("[[\"project_type:mod\"],[\"categories:{}\"]]", tag)
112        };
113
114        let resp: Resp = self
115            .client
116            .get(format!("{}/search", MR_BASE))
117            .query(&[("query", name), ("facets", &facets), ("limit", "20")])
118            .send()
119            .await?
120            .error_for_status()?
121            .json()
122            .await?;
123
124        Ok(resp.hits)
125    }
126
127    /// Versions of a project matching the given MC version and loader.
128    /// `id` may be a project ID or slug.
129    pub async fn get_project_versions(
130        &self,
131        id: &str,
132        mc_version: &str,
133        loader: &ModLoader,
134    ) -> Result<Vec<MrVersion>> {
135        let mut query: Vec<(&str, String)> =
136            vec![("game_versions", format!("[\"{}\"]", mc_version))];
137        let tag = loader_tag(loader);
138        if !tag.is_empty() {
139            query.push(("loaders", format!("[\"{}\"]", tag)));
140        }
141
142        let versions: Vec<MrVersion> = self
143            .client
144            .get(format!("{}/project/{}/version", MR_BASE, id))
145            .query(&query)
146            .send()
147            .await?
148            .error_for_status()?
149            .json()
150            .await?;
151
152        Ok(versions)
153    }
154
155    /// Newest version (by publish date) matching the MC version and loader.
156    pub async fn get_latest_version(
157        &self,
158        id: &str,
159        mc_version: &str,
160        loader: &ModLoader,
161    ) -> Result<Option<MrVersion>> {
162        let versions = self.get_project_versions(id, mc_version, loader).await?;
163        Ok(versions
164            .into_iter()
165            .max_by(|a, b| a.date_published.cmp(&b.date_published)))
166    }
167
168    /// Identify a locally installed file by its SHA1. Returns `None` if Modrinth
169    /// doesn't know the hash.
170    pub async fn get_version_by_hash(&self, sha1: &str) -> Result<Option<MrVersion>> {
171        let resp = self
172            .client
173            .get(format!("{}/version_file/{}", MR_BASE, sha1))
174            .query(&[("algorithm", "sha1")])
175            .send()
176            .await?;
177
178        if resp.status() == reqwest::StatusCode::NOT_FOUND {
179            return Ok(None);
180        }
181
182        let version: MrVersion = resp.error_for_status()?.json().await?;
183        Ok(Some(version))
184    }
185
186    /// Latest version for a file identified by its SHA1, constrained to the given
187    /// MC version and loader. Returns `None` if the hash is unknown to Modrinth.
188    pub async fn get_latest_by_hash(
189        &self,
190        sha1: &str,
191        mc_version: &str,
192        loader: &ModLoader,
193    ) -> Result<Option<MrVersion>> {
194        #[derive(Serialize)]
195        struct Body {
196            loaders: Vec<String>,
197            game_versions: Vec<String>,
198        }
199
200        let tag = loader_tag(loader);
201        let loaders = if tag.is_empty() {
202            Vec::new()
203        } else {
204            vec![tag.to_string()]
205        };
206
207        let resp = self
208            .client
209            .post(format!("{}/version_file/{}/update", MR_BASE, sha1))
210            .query(&[("algorithm", "sha1")])
211            .json(&Body {
212                loaders,
213                game_versions: vec![mc_version.to_string()],
214            })
215            .send()
216            .await?;
217
218        if resp.status() == reqwest::StatusCode::NOT_FOUND {
219            return Ok(None);
220        }
221
222        let version: MrVersion = resp.error_for_status()?.json().await?;
223        Ok(Some(version))
224    }
225}