1use anyhow::{Result, bail};
4use modde_core::manifest::collection::CollectionManifest;
5use modde_core::{NexusFileId, NexusModId};
6use reqwest::Client;
7use serde::Deserialize;
8use tracing::warn;
9
10pub struct NexusApi {
12 client: Client,
13 api_key: String,
14}
15
16#[derive(Debug, Clone, Deserialize)]
18pub struct NexusMod {
19 pub mod_id: NexusModId,
20 pub name: String,
21 pub summary: Option<String>,
22 pub version: String,
23 pub author: String,
24 #[serde(default)]
26 pub picture_url: Option<String>,
27 #[serde(default)]
29 pub description: Option<String>,
30 #[serde(default)]
32 pub domain_name: Option<String>,
33 #[serde(default)]
36 pub endorsement: Option<NexusEndorsement>,
37 #[serde(default)]
39 pub endorsement_count: u64,
40}
41
42#[derive(Debug, Clone, Deserialize)]
48pub struct NexusEndorsement {
49 pub endorse_status: String,
50 #[serde(default)]
51 pub timestamp: Option<u64>,
52 #[serde(default)]
53 pub version: Option<String>,
54}
55
56#[derive(Debug, Clone, Deserialize)]
58pub struct NexusTrackedMod {
59 pub mod_id: NexusModId,
60 pub domain_name: String,
61}
62
63#[derive(Debug, Deserialize)]
65pub struct NexusModFile {
66 pub file_id: NexusFileId,
67 pub name: String,
68 pub version: Option<String>,
69 pub size_kb: Option<u64>,
70 pub file_name: String,
71 #[serde(default)]
73 pub category_name: Option<String>,
74 #[serde(default)]
76 pub uploaded_timestamp: Option<u64>,
77}
78
79#[derive(Debug, Deserialize)]
84pub struct NexusCollectionMeta {
85 pub game: NexusCollectionGame,
86 #[serde(default)]
87 pub latest_published_revision: Option<NexusCollectionRevision>,
88}
89
90#[derive(Debug, Deserialize)]
92pub struct NexusCollectionGame {
93 pub domain_name: String,
94}
95
96#[derive(Debug, Deserialize)]
98pub struct NexusCollectionRevision {
99 pub revision_number: u64,
100}
101
102#[derive(Debug, Deserialize)]
104pub struct NexusModFiles {
105 pub files: Vec<NexusModFile>,
106}
107
108#[derive(Debug, Deserialize)]
110pub struct NexusSearchResults {
111 pub results: Vec<NexusMod>,
112 pub total: u64,
113}
114
115#[derive(Debug, Deserialize)]
117pub struct NexusUpdatedMod {
118 pub mod_id: NexusModId,
119 pub latest_file_update: u64,
120 pub latest_mod_activity: u64,
121}
122
123impl NexusApi {
124 #[must_use]
126 pub fn new(client: Client, api_key: String) -> Self {
127 Self { client, api_key }
128 }
129
130 async fn get<T: serde::de::DeserializeOwned>(&self, url: &str) -> Result<T> {
131 let resp = self
132 .client
133 .get(url)
134 .header("apikey", &self.api_key)
135 .send()
136 .await?;
137
138 if let Some(remaining) = resp.headers().get("x-rl-hourly-remaining")
140 && let Ok(val) = remaining.to_str().unwrap_or("").parse::<u32>()
141 && val < 10
142 {
143 warn!(remaining = val, "Nexus API hourly rate limit running low");
144 }
145
146 if resp.status() == 429 {
147 bail!("Nexus API rate limit exceeded. Please wait before retrying.");
148 }
149
150 let body = resp.error_for_status()?.json().await?;
151 Ok(body)
152 }
153
154 async fn delete_req(&self, url: &str, form: &[(&str, &str)]) -> Result<()> {
155 self.client
156 .delete(url)
157 .header("apikey", &self.api_key)
158 .form(form)
159 .send()
160 .await?
161 .error_for_status()?;
162 Ok(())
163 }
164
165 pub async fn get_mod(&self, game_domain: &str, mod_id: NexusModId) -> Result<NexusMod> {
167 let url = format!(
168 "{}/games/{game_domain}/mods/{mod_id}.json",
169 super::base_url()
170 );
171 self.get(&url).await
172 }
173
174 pub async fn browse_feed_gql(
181 &self,
182 game_domain: &str,
183 kind: super::graphql::ModFeedKind,
184 ) -> Result<Vec<super::graphql::GqlModTile>> {
185 match super::graphql::browse_feed(&self.client, &self.api_key, game_domain, kind).await {
186 Ok(tiles) => Ok(tiles),
187 Err(e) => {
188 warn!(error = %e, "GraphQL browse feed failed, falling back to REST");
189 let mods = self.trending_mods(game_domain).await?;
190 Ok(mods
191 .into_iter()
192 .map(|m| super::graphql::GqlModTile {
193 mod_id: m.mod_id,
194 name: m.name,
195 summary: m.summary,
196 version: Some(m.version),
197 author: Some(m.author),
198 picture_url: m.picture_url.clone(),
199 thumbnail_url: m.picture_url,
200 endorsements: Some(m.endorsement_count),
201 downloads: None,
202 uploaded_at: None,
203 game_domain: m.domain_name,
204 })
205 .collect())
206 }
207 }
208 }
209
210 pub async fn search_mods_gql(
213 &self,
214 game_domain: &str,
215 term: &str,
216 page: u32,
217 ) -> Result<Vec<super::graphql::GqlModTile>> {
218 match super::graphql::search_mods(&self.client, &self.api_key, game_domain, term, page)
219 .await
220 {
221 Ok(tiles) => Ok(tiles),
222 Err(e) => {
223 warn!(error = %e, "GraphQL search failed, falling back to REST");
224 let results = self.search_mods(game_domain, term, page).await?;
225 Ok(results
226 .results
227 .into_iter()
228 .map(|m| super::graphql::GqlModTile {
229 mod_id: m.mod_id,
230 name: m.name,
231 summary: m.summary,
232 version: Some(m.version),
233 author: Some(m.author),
234 picture_url: m.picture_url.clone(),
235 thumbnail_url: m.picture_url,
236 endorsements: Some(m.endorsement_count),
237 downloads: None,
238 uploaded_at: None,
239 game_domain: m.domain_name,
240 })
241 .collect())
242 }
243 }
244 }
245
246 pub async fn collections_feed_gql(
249 &self,
250 game_domain: &str,
251 term: Option<&str>,
252 ) -> Result<Vec<super::graphql::GqlCollectionTile>> {
253 match super::graphql::collections_feed(&self.client, &self.api_key, game_domain, term).await
254 {
255 Ok(tiles) => Ok(tiles),
256 Err(e) => {
257 warn!(error = %e, "GraphQL collections feed failed, falling back to REST");
258 let results = self
259 .search_collections(game_domain, term.unwrap_or(""))
260 .await?;
261 Ok(results
262 .into_iter()
263 .map(|c| super::graphql::GqlCollectionTile {
264 slug: c.slug,
265 name: c.name,
266 summary: c.summary,
267 tile_image: c.image_url,
268 game_domain: Some(c.game.domain_name),
269 endorsements: Some(c.endorsements),
270 downloads: None,
271 })
272 .collect())
273 }
274 }
275 }
276
277 pub async fn fetch_bytes(&self, url: &str) -> Result<Vec<u8>> {
283 let resp = self
284 .client
285 .get(url)
286 .header("apikey", &self.api_key)
287 .send()
288 .await?
289 .error_for_status()?;
290 Ok(resp.bytes().await?.to_vec())
291 }
292
293 pub async fn get_mod_media(
302 &self,
303 game_domain: &str,
304 mod_id: NexusModId,
305 ) -> Result<Vec<String>> {
306 let query = r"query ModMedia($modId: Int!, $gameDomain: String!) {
307 mod(modId: $modId, gameDomain: $gameDomain) {
308 modImages { url }
309 }
310}";
311 let body = serde_json::json!({
312 "query": query,
313 "variables": {
314 "modId": mod_id.get(),
315 "gameDomain": game_domain,
316 },
317 });
318
319 let resp = self
320 .client
321 .post(super::graphql_url())
322 .header("apikey", &self.api_key)
323 .header("content-type", "application/json")
324 .json(&body)
325 .send()
326 .await?
327 .error_for_status()?;
328
329 let payload: serde_json::Value = resp.json().await?;
330 if let Some(errors) = payload.get("errors") {
331 bail!("Nexus GraphQL errors: {errors}");
332 }
333 let images = payload
334 .get("data")
335 .and_then(|d| d.get("mod"))
336 .and_then(|m| m.get("modImages"))
337 .and_then(|a| a.as_array())
338 .ok_or_else(|| anyhow::anyhow!("unexpected GraphQL response shape"))?;
339
340 let urls: Vec<String> = images
341 .iter()
342 .filter_map(|img| {
343 img.get("url")
344 .and_then(|u| u.as_str())
345 .map(std::string::ToString::to_string)
346 })
347 .collect();
348 Ok(urls)
349 }
350
351 pub async fn get_mod_files(
353 &self,
354 game_domain: &str,
355 mod_id: NexusModId,
356 ) -> Result<NexusModFiles> {
357 let url = format!(
358 "{}/games/{game_domain}/mods/{mod_id}/files.json",
359 super::base_url()
360 );
361 self.get(&url).await
362 }
363
364 pub async fn search_mods(
366 &self,
367 game_domain: &str,
368 query: &str,
369 page: u32,
370 ) -> Result<NexusSearchResults> {
371 let url = format!(
372 "{}/games/{game_domain}/mods/search.json?search={query}&page={page}",
373 super::base_url()
374 );
375 self.get(&url).await
376 }
377
378 pub async fn trending_mods(&self, game_domain: &str) -> Result<Vec<NexusMod>> {
380 let url = format!(
381 "{}/games/{game_domain}/mods/trending.json",
382 super::base_url()
383 );
384 self.get(&url).await
385 }
386
387 pub async fn updated_mods(
389 &self,
390 game_domain: &str,
391 period: &str,
392 ) -> Result<Vec<NexusUpdatedMod>> {
393 let url = format!(
394 "{}/games/{game_domain}/mods/updated.json?period={period}",
395 super::base_url()
396 );
397 self.get(&url).await
398 }
399
400 pub async fn search_collections(
402 &self,
403 game_domain: &str,
404 query: &str,
405 ) -> Result<Vec<CollectionManifest>> {
406 let url = format!(
407 "{}/games/{game_domain}/collections.json?search={query}",
408 super::base_url()
409 );
410 self.get(&url).await
411 }
412
413 pub async fn get_collection(
415 &self,
416 game_domain: &str,
417 slug: &str,
418 ) -> Result<CollectionManifest> {
419 let url = format!(
420 "{}/games/{game_domain}/collections/{slug}.json",
421 super::base_url()
422 );
423 self.get(&url).await
424 }
425
426 pub async fn get_collection_revision(
428 &self,
429 game_domain: &str,
430 slug: &str,
431 revision: u64,
432 ) -> Result<CollectionManifest> {
433 let url = format!(
434 "{}/games/{game_domain}/collections/{slug}/revisions/{revision}.json",
435 super::base_url()
436 );
437 self.get(&url).await
438 }
439
440 pub async fn get_collection_meta(&self, slug: &str) -> Result<NexusCollectionMeta> {
444 let url = format!("{}/collections/{slug}.json", super::base_url());
447 self.get(&url).await
448 }
449
450 pub async fn endorse_mod(
458 &self,
459 game_domain: &str,
460 mod_id: NexusModId,
461 version: &str,
462 ) -> Result<()> {
463 let url = format!(
464 "{}/games/{game_domain}/mods/{mod_id}/endorse.json",
465 super::base_url()
466 );
467 self.client
468 .post(&url)
469 .header("apikey", &self.api_key)
470 .form(&[("Version", version)])
471 .send()
472 .await?
473 .error_for_status()?;
474 Ok(())
475 }
476
477 pub async fn abstain_mod(
479 &self,
480 game_domain: &str,
481 mod_id: NexusModId,
482 version: &str,
483 ) -> Result<()> {
484 let url = format!(
485 "{}/games/{game_domain}/mods/{mod_id}/abstain.json",
486 super::base_url()
487 );
488 self.client
489 .post(&url)
490 .header("apikey", &self.api_key)
491 .form(&[("Version", version)])
492 .send()
493 .await?
494 .error_for_status()?;
495 Ok(())
496 }
497
498 pub async fn get_tracked_mods(&self) -> Result<Vec<NexusTrackedMod>> {
502 let url = format!("{}/user/tracked_mods.json", super::base_url());
503 self.get(&url).await
504 }
505
506 pub async fn track_mod(&self, game_domain: &str, mod_id: NexusModId) -> Result<()> {
508 let url = format!("{}/user/tracked_mods.json", super::base_url());
509 self.client
510 .post(&url)
511 .header("apikey", &self.api_key)
512 .form(&[
513 ("domain_name", game_domain),
514 ("mod_id", &mod_id.to_string()),
515 ])
516 .send()
517 .await?
518 .error_for_status()?;
519 Ok(())
520 }
521
522 pub async fn untrack_mod(&self, game_domain: &str, mod_id: NexusModId) -> Result<()> {
524 let url = format!("{}/user/tracked_mods.json", super::base_url());
525 self.delete_req(
526 &url,
527 &[
528 ("domain_name", game_domain),
529 ("mod_id", &mod_id.to_string()),
530 ],
531 )
532 .await
533 }
534
535 pub async fn get_collection_by_slug(
540 &self,
541 slug: &str,
542 version: Option<u64>,
543 ) -> Result<CollectionManifest> {
544 let (game_domain, revision) = if let Some(rev) = version {
545 let meta = self.get_collection_meta(slug).await?;
547 (meta.game.domain_name, rev)
548 } else {
549 let meta = self.get_collection_meta(slug).await?;
550 let rev = meta
551 .latest_published_revision
552 .map(|r| r.revision_number)
553 .ok_or_else(|| anyhow::anyhow!("collection '{slug}' has no published revisions"))?;
554 (meta.game.domain_name, rev)
555 };
556
557 self.get_collection_revision(&game_domain, slug, revision)
558 .await
559 }
560}