Skip to main content

hexomc_lib/mods/
curseforge.rs

1use serde::{Deserialize, Serialize};
2
3use crate::error::Result;
4use crate::mods::detector::ModLoader;
5
6const CF_BASE: &str = "https://api.curseforge.com/v1";
7
8pub struct CurseForgeClient {
9    client: reqwest::Client,
10    api_key: String,
11}
12
13impl CurseForgeClient {
14    pub fn new(api_key: impl Into<String>) -> Self {
15        Self {
16            client: reqwest::Client::new(),
17            api_key: api_key.into(),
18        }
19    }
20
21    fn headers(&self) -> reqwest::header::HeaderMap {
22        let mut headers = reqwest::header::HeaderMap::new();
23        headers.insert("x-api-key", self.api_key.parse().unwrap());
24        headers.insert("Content-Type", "application/json".parse().unwrap());
25        headers
26    }
27}
28
29#[derive(Debug, Deserialize, Serialize, Clone)]
30pub struct CfMod {
31    pub id: u64,
32    pub name: String,
33    pub slug: String,
34    pub summary: String,
35    #[serde(rename = "downloadCount")]
36    pub download_count: u64,
37    /// Project category: 6 = mods, 12 = resource packs, 6552 = shaders.
38    #[serde(rename = "classId", default)]
39    pub class_id: Option<u32>,
40    #[serde(default)]
41    pub links: Option<CfLinks>,
42}
43
44#[derive(Debug, Deserialize, Serialize, Clone)]
45pub struct CfLinks {
46    #[serde(rename = "websiteUrl", default)]
47    pub website_url: Option<String>,
48}
49
50#[derive(Debug, Deserialize, Serialize, Clone)]
51pub struct CfModFile {
52    pub id: u64,
53    #[serde(rename = "modId", default)]
54    pub mod_id: u64,
55    #[serde(rename = "displayName")]
56    pub display_name: String,
57    #[serde(rename = "fileName")]
58    pub file_name: String,
59    #[serde(rename = "downloadUrl")]
60    pub download_url: Option<String>,
61    #[serde(rename = "gameVersions")]
62    pub game_versions: Vec<String>,
63    #[serde(rename = "fileDate")]
64    pub file_date: String,
65    pub hashes: Vec<CfHash>,
66}
67
68#[derive(Debug, Deserialize, Serialize, Clone)]
69pub struct CfHash {
70    pub value: String,
71    pub algo: u32, // 1=Sha1, 2=Md5
72}
73
74impl CfModFile {
75    pub fn sha1(&self) -> Option<&str> {
76        self.hashes
77            .iter()
78            .find(|h| h.algo == 1)
79            .map(|h| h.value.as_str())
80    }
81}
82
83fn loader_type_id(loader: &ModLoader) -> u32 {
84    match loader {
85        ModLoader::Forge => 1,
86        ModLoader::Fabric => 4,
87        ModLoader::Quilt => 5,
88        ModLoader::NeoForge => 6,
89        ModLoader::Unknown => 0,
90    }
91}
92
93impl CurseForgeClient {
94    /// Search for mods.
95    pub async fn search_mod(
96        &self,
97        name: &str,
98        loader: &ModLoader,
99    ) -> Result<Vec<CfMod>> {
100        #[derive(Deserialize)]
101        struct Resp {
102            data: Vec<CfMod>,
103        }
104
105        let resp: Resp = self
106            .client
107            .get(format!("{}/mods/search", CF_BASE))
108            .headers(self.headers())
109            .query(&[
110                ("gameId", "432"),
111                ("searchFilter", name),
112                ("modLoaderType", &loader_type_id(loader).to_string()),
113                ("sortOrder", "desc"),
114            ])
115            .send()
116            .await?
117            .json()
118            .await?;
119
120        Ok(resp.data)
121    }
122
123    /// All file versions of a mod.
124    pub async fn get_mod_files(&self, mod_id: u64) -> Result<Vec<CfModFile>> {
125        #[derive(Deserialize)]
126        struct Resp {
127            data: Vec<CfModFile>,
128        }
129
130        let resp: Resp = self
131            .client
132            .get(format!("{}/mods/{}/files", CF_BASE, mod_id))
133            .headers(self.headers())
134            .send()
135            .await?
136            .json()
137            .await?;
138
139        Ok(resp.data)
140    }
141
142    /// Look up several files at once by file ID.
143    pub async fn get_files(&self, file_ids: &[u64]) -> Result<Vec<CfModFile>> {
144        #[derive(Serialize)]
145        struct Body<'a> {
146            #[serde(rename = "fileIds")]
147            file_ids: &'a [u64],
148        }
149        #[derive(Deserialize)]
150        struct Resp {
151            data: Vec<CfModFile>,
152        }
153
154        let resp: Resp = self
155            .client
156            .post(format!("{}/mods/files", CF_BASE))
157            .headers(self.headers())
158            .json(&Body { file_ids })
159            .send()
160            .await?
161            .error_for_status()?
162            .json()
163            .await?;
164
165        Ok(resp.data)
166    }
167
168    /// Look up several mods at once by project ID.
169    pub async fn get_mods(&self, mod_ids: &[u64]) -> Result<Vec<CfMod>> {
170        #[derive(Serialize)]
171        struct Body<'a> {
172            #[serde(rename = "modIds")]
173            mod_ids: &'a [u64],
174        }
175        #[derive(Deserialize)]
176        struct Resp {
177            data: Vec<CfMod>,
178        }
179
180        let resp: Resp = self
181            .client
182            .post(format!("{}/mods", CF_BASE))
183            .headers(self.headers())
184            .json(&Body { mod_ids })
185            .send()
186            .await?
187            .error_for_status()?
188            .json()
189            .await?;
190
191        Ok(resp.data)
192    }
193
194    /// Latest file matching the given MC version and loader.
195    pub async fn get_latest_file(
196        &self,
197        mod_id: u64,
198        mc_version: &str,
199        loader: &ModLoader,
200    ) -> Result<Option<CfModFile>> {
201        let files = self.get_mod_files(mod_id).await?;
202        let loader_str = format!("{:?}", loader);
203
204        let best = files
205            .into_iter()
206            .filter(|f| {
207                f.game_versions.iter().any(|v| v == mc_version)
208                    && f.game_versions
209                        .iter()
210                        .any(|v| v.to_lowercase() == loader_str.to_lowercase())
211            })
212            .max_by_key(|f| f.file_date.clone());
213
214        Ok(best)
215    }
216}