hexomc_lib/mods/
modrinth.rs1use 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 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
88fn 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 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 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 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 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 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}