1use anyhow::{Result, bail};
2use modde_core::manifest::collection::CollectionManifest;
3use reqwest::Client;
4use serde::Deserialize;
5use tracing::warn;
6
7const BASE_URL: &str = "https://api.nexusmods.com/v1";
8
9pub struct NexusApi {
11 client: Client,
12 api_key: String,
13}
14
15#[derive(Debug, Clone, Deserialize)]
16pub struct NexusMod {
17 pub mod_id: u64,
18 pub name: String,
19 pub summary: Option<String>,
20 pub version: String,
21 pub author: String,
22 #[serde(default)]
24 pub picture_url: Option<String>,
25 #[serde(default)]
27 pub description: Option<String>,
28 #[serde(default)]
30 pub domain_name: Option<String>,
31 #[serde(default)]
34 pub endorsement: Option<NexusEndorsement>,
35 #[serde(default)]
37 pub endorsement_count: u64,
38}
39
40#[derive(Debug, Clone, Deserialize)]
46pub struct NexusEndorsement {
47 pub endorse_status: String,
48 #[serde(default)]
49 pub timestamp: Option<u64>,
50 #[serde(default)]
51 pub version: Option<String>,
52}
53
54#[derive(Debug, Clone, Deserialize)]
56pub struct NexusTrackedMod {
57 pub mod_id: u64,
58 pub domain_name: String,
59}
60
61#[derive(Debug, Deserialize)]
62pub struct NexusModFile {
63 pub file_id: u64,
64 pub name: String,
65 pub version: Option<String>,
66 pub size_kb: Option<u64>,
67 pub file_name: String,
68 #[serde(default)]
70 pub category_name: Option<String>,
71 #[serde(default)]
73 pub uploaded_timestamp: Option<u64>,
74}
75
76#[derive(Debug, Deserialize)]
81pub struct NexusCollectionMeta {
82 pub game: NexusCollectionGame,
83 #[serde(default)]
84 pub latest_published_revision: Option<NexusCollectionRevision>,
85}
86
87#[derive(Debug, Deserialize)]
88pub struct NexusCollectionGame {
89 pub domain_name: String,
90}
91
92#[derive(Debug, Deserialize)]
93pub struct NexusCollectionRevision {
94 pub revision_number: u64,
95}
96
97#[derive(Debug, Deserialize)]
98pub struct NexusModFiles {
99 pub files: Vec<NexusModFile>,
100}
101
102#[derive(Debug, Deserialize)]
103pub struct NexusSearchResults {
104 pub results: Vec<NexusMod>,
105 pub total: u64,
106}
107
108#[derive(Debug, Deserialize)]
109pub struct NexusUpdatedMod {
110 pub mod_id: u64,
111 pub latest_file_update: u64,
112 pub latest_mod_activity: u64,
113}
114
115impl NexusApi {
116 pub fn new(client: Client, api_key: String) -> Self {
117 Self { client, api_key }
118 }
119
120 async fn get<T: serde::de::DeserializeOwned>(&self, url: &str) -> Result<T> {
121 let resp = self
122 .client
123 .get(url)
124 .header("apikey", &self.api_key)
125 .send()
126 .await?;
127
128 if let Some(remaining) = resp.headers().get("x-rl-hourly-remaining") {
130 if let Ok(val) = remaining.to_str().unwrap_or("").parse::<u32>() {
131 if val < 10 {
132 warn!(remaining = val, "Nexus API hourly rate limit running low");
133 }
134 }
135 }
136
137 if resp.status() == 429 {
138 bail!("Nexus API rate limit exceeded. Please wait before retrying.");
139 }
140
141 let body = resp.error_for_status()?.json().await?;
142 Ok(body)
143 }
144
145 async fn delete_req(&self, url: &str, form: &[(&str, &str)]) -> Result<()> {
146 self.client
147 .delete(url)
148 .header("apikey", &self.api_key)
149 .form(form)
150 .send()
151 .await?
152 .error_for_status()?;
153 Ok(())
154 }
155
156 pub async fn get_mod(&self, game_domain: &str, mod_id: u64) -> Result<NexusMod> {
158 let url = format!("{BASE_URL}/games/{game_domain}/mods/{mod_id}.json");
159 self.get(&url).await
160 }
161
162 pub async fn browse_feed_gql(
169 &self,
170 game_domain: &str,
171 kind: super::graphql::ModFeedKind,
172 ) -> Result<Vec<super::graphql::GqlModTile>> {
173 match super::graphql::browse_feed(&self.client, &self.api_key, game_domain, kind).await {
174 Ok(tiles) => Ok(tiles),
175 Err(e) => {
176 warn!(error = %e, "GraphQL browse feed failed, falling back to REST");
177 let mods = self.trending_mods(game_domain).await?;
178 Ok(mods
179 .into_iter()
180 .map(|m| super::graphql::GqlModTile {
181 mod_id: m.mod_id,
182 name: m.name,
183 summary: m.summary,
184 version: Some(m.version),
185 author: Some(m.author),
186 picture_url: m.picture_url.clone(),
187 thumbnail_url: m.picture_url,
188 endorsements: Some(m.endorsement_count),
189 downloads: None,
190 uploaded_at: None,
191 game_domain: m.domain_name,
192 })
193 .collect())
194 }
195 }
196 }
197
198 pub async fn search_mods_gql(
201 &self,
202 game_domain: &str,
203 term: &str,
204 page: u32,
205 ) -> Result<Vec<super::graphql::GqlModTile>> {
206 match super::graphql::search_mods(&self.client, &self.api_key, game_domain, term, page)
207 .await
208 {
209 Ok(tiles) => Ok(tiles),
210 Err(e) => {
211 warn!(error = %e, "GraphQL search failed, falling back to REST");
212 let results = self.search_mods(game_domain, term, page).await?;
213 Ok(results
214 .results
215 .into_iter()
216 .map(|m| super::graphql::GqlModTile {
217 mod_id: m.mod_id,
218 name: m.name,
219 summary: m.summary,
220 version: Some(m.version),
221 author: Some(m.author),
222 picture_url: m.picture_url.clone(),
223 thumbnail_url: m.picture_url,
224 endorsements: Some(m.endorsement_count),
225 downloads: None,
226 uploaded_at: None,
227 game_domain: m.domain_name,
228 })
229 .collect())
230 }
231 }
232 }
233
234 pub async fn collections_feed_gql(
237 &self,
238 game_domain: &str,
239 term: Option<&str>,
240 ) -> Result<Vec<super::graphql::GqlCollectionTile>> {
241 match super::graphql::collections_feed(&self.client, &self.api_key, game_domain, term)
242 .await
243 {
244 Ok(tiles) => Ok(tiles),
245 Err(e) => {
246 warn!(error = %e, "GraphQL collections feed failed, falling back to REST");
247 let results = self
248 .search_collections(game_domain, term.unwrap_or(""))
249 .await?;
250 Ok(results
251 .into_iter()
252 .map(|c| super::graphql::GqlCollectionTile {
253 slug: c.slug,
254 name: c.name,
255 summary: c.summary,
256 tile_image: c.image_url,
257 game_domain: Some(c.game.domain_name),
258 endorsements: Some(c.endorsements),
259 downloads: None,
260 })
261 .collect())
262 }
263 }
264 }
265
266 pub async fn fetch_bytes(&self, url: &str) -> Result<Vec<u8>> {
272 let resp = self
273 .client
274 .get(url)
275 .header("apikey", &self.api_key)
276 .send()
277 .await?
278 .error_for_status()?;
279 Ok(resp.bytes().await?.to_vec())
280 }
281
282 pub async fn get_mod_media(&self, game_domain: &str, mod_id: u64) -> Result<Vec<String>> {
291 let query = r#"query ModMedia($modId: Int!, $gameDomain: String!) {
292 mod(modId: $modId, gameDomain: $gameDomain) {
293 modImages { url }
294 }
295}"#;
296 let body = serde_json::json!({
297 "query": query,
298 "variables": {
299 "modId": mod_id,
300 "gameDomain": game_domain,
301 },
302 });
303
304 let resp = self
305 .client
306 .post("https://api.nexusmods.com/v2/graphql")
307 .header("apikey", &self.api_key)
308 .header("content-type", "application/json")
309 .json(&body)
310 .send()
311 .await?
312 .error_for_status()?;
313
314 let payload: serde_json::Value = resp.json().await?;
315 if let Some(errors) = payload.get("errors") {
316 bail!("Nexus GraphQL errors: {errors}");
317 }
318 let images = payload
319 .get("data")
320 .and_then(|d| d.get("mod"))
321 .and_then(|m| m.get("modImages"))
322 .and_then(|a| a.as_array())
323 .ok_or_else(|| anyhow::anyhow!("unexpected GraphQL response shape"))?;
324
325 let urls: Vec<String> = images
326 .iter()
327 .filter_map(|img| img.get("url").and_then(|u| u.as_str()).map(|s| s.to_string()))
328 .collect();
329 Ok(urls)
330 }
331
332 pub async fn get_mod_files(&self, game_domain: &str, mod_id: u64) -> Result<NexusModFiles> {
334 let url = format!("{BASE_URL}/games/{game_domain}/mods/{mod_id}/files.json");
335 self.get(&url).await
336 }
337
338 pub async fn search_mods(
340 &self,
341 game_domain: &str,
342 query: &str,
343 page: u32,
344 ) -> Result<NexusSearchResults> {
345 let url = format!(
346 "{BASE_URL}/games/{game_domain}/mods/search.json?search={query}&page={page}",
347 );
348 self.get(&url).await
349 }
350
351 pub async fn trending_mods(&self, game_domain: &str) -> Result<Vec<NexusMod>> {
353 let url = format!("{BASE_URL}/games/{game_domain}/mods/trending.json");
354 self.get(&url).await
355 }
356
357 pub async fn updated_mods(
359 &self,
360 game_domain: &str,
361 period: &str,
362 ) -> Result<Vec<NexusUpdatedMod>> {
363 let url = format!("{BASE_URL}/games/{game_domain}/mods/updated.json?period={period}");
364 self.get(&url).await
365 }
366
367 pub async fn search_collections(
369 &self,
370 game_domain: &str,
371 query: &str,
372 ) -> Result<Vec<CollectionManifest>> {
373 let url = format!(
374 "{BASE_URL}/games/{game_domain}/collections.json?search={query}",
375 );
376 self.get(&url).await
377 }
378
379 pub async fn get_collection(
381 &self,
382 game_domain: &str,
383 slug: &str,
384 ) -> Result<CollectionManifest> {
385 let url = format!("{BASE_URL}/games/{game_domain}/collections/{slug}.json");
386 self.get(&url).await
387 }
388
389 pub async fn get_collection_revision(
391 &self,
392 game_domain: &str,
393 slug: &str,
394 revision: u64,
395 ) -> Result<CollectionManifest> {
396 let url = format!(
397 "{BASE_URL}/games/{game_domain}/collections/{slug}/revisions/{revision}.json"
398 );
399 self.get(&url).await
400 }
401
402 pub async fn get_collection_meta(&self, slug: &str) -> Result<NexusCollectionMeta> {
406 let url = format!("{BASE_URL}/collections/{slug}.json");
409 self.get(&url).await
410 }
411
412 pub async fn endorse_mod(
420 &self,
421 game_domain: &str,
422 mod_id: u64,
423 version: &str,
424 ) -> Result<()> {
425 let url = format!("{BASE_URL}/games/{game_domain}/mods/{mod_id}/endorse.json");
426 self.client
427 .post(&url)
428 .header("apikey", &self.api_key)
429 .form(&[("Version", version)])
430 .send()
431 .await?
432 .error_for_status()?;
433 Ok(())
434 }
435
436 pub async fn abstain_mod(
438 &self,
439 game_domain: &str,
440 mod_id: u64,
441 version: &str,
442 ) -> Result<()> {
443 let url = format!("{BASE_URL}/games/{game_domain}/mods/{mod_id}/abstain.json");
444 self.client
445 .post(&url)
446 .header("apikey", &self.api_key)
447 .form(&[("Version", version)])
448 .send()
449 .await?
450 .error_for_status()?;
451 Ok(())
452 }
453
454 pub async fn get_tracked_mods(&self) -> Result<Vec<NexusTrackedMod>> {
458 let url = format!("{BASE_URL}/user/tracked_mods.json");
459 self.get(&url).await
460 }
461
462 pub async fn track_mod(&self, game_domain: &str, mod_id: u64) -> Result<()> {
464 let url = format!("{BASE_URL}/user/tracked_mods.json");
465 self.client
466 .post(&url)
467 .header("apikey", &self.api_key)
468 .form(&[
469 ("domain_name", game_domain),
470 ("mod_id", &mod_id.to_string()),
471 ])
472 .send()
473 .await?
474 .error_for_status()?;
475 Ok(())
476 }
477
478 pub async fn untrack_mod(&self, game_domain: &str, mod_id: u64) -> Result<()> {
480 let url = format!("{BASE_URL}/user/tracked_mods.json");
481 self.delete_req(
482 &url,
483 &[
484 ("domain_name", game_domain),
485 ("mod_id", &mod_id.to_string()),
486 ],
487 )
488 .await
489 }
490
491 pub async fn get_collection_by_slug(
496 &self,
497 slug: &str,
498 version: Option<u64>,
499 ) -> Result<CollectionManifest> {
500 let (game_domain, revision) = match version {
501 Some(rev) => {
502 let meta = self.get_collection_meta(slug).await?;
504 (meta.game.domain_name, rev)
505 }
506 None => {
507 let meta = self.get_collection_meta(slug).await?;
508 let rev = meta
509 .latest_published_revision
510 .map(|r| r.revision_number)
511 .ok_or_else(|| anyhow::anyhow!(
512 "collection '{slug}' has no published revisions"
513 ))?;
514 (meta.game.domain_name, rev)
515 }
516 };
517
518 self.get_collection_revision(&game_domain, slug, revision).await
519 }
520}