Skip to main content

modde_sources/nexus/
api.rs

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
9/// Typed Nexus API client.
10pub 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    /// Primary thumbnail URL (full-size picture shown at the top of the mod page).
23    #[serde(default)]
24    pub picture_url: Option<String>,
25    /// Long-form HTML description. May contain BBCode-derived markup.
26    #[serde(default)]
27    pub description: Option<String>,
28    /// Nexus game domain the mod belongs to (e.g. `"skyrimspecialedition"`).
29    #[serde(default)]
30    pub domain_name: Option<String>,
31    /// The current user's endorsement relationship to this mod. Only
32    /// populated on authenticated requests. Absent otherwise.
33    #[serde(default)]
34    pub endorsement: Option<NexusEndorsement>,
35    /// Total endorsements the mod has received (not user-specific).
36    #[serde(default)]
37    pub endorsement_count: u64,
38}
39
40/// The current user's endorsement status for a mod.
41///
42/// `endorse_status` values returned by Nexus v1: `"Undecided"`, `"Abstained"`,
43/// `"Endorsed"`. See `node-nexus-api/lib/types.d.ts` (`EndorsedStatus`) for the
44/// canonical enum.
45#[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/// A single entry in the user's tracked-mods list.
55#[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    /// File category: `"MAIN"`, `"UPDATE"`, `"OPTIONAL"`, `"OLD_VERSION"`, `"MISCELLANEOUS"`.
69    #[serde(default)]
70    pub category_name: Option<String>,
71    /// Upload timestamp (Unix epoch seconds). Used to pick the most-recent MAIN file.
72    #[serde(default)]
73    pub uploaded_timestamp: Option<u64>,
74}
75
76/// Minimal collection metadata returned by the slug-based lookup endpoint.
77///
78/// Used in the two-step collection install flow to discover the game domain
79/// before fetching the full revision manifest.
80#[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        // Check rate limit headers
129        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    /// Get mod details.
157    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    // ── GraphQL v2 browse helpers ─────────────────────────────
163
164    /// Fetch a trending or monthly-top browse feed via the v2 GraphQL
165    /// endpoint. Falls back to the REST `trending_mods` path when the
166    /// GraphQL response is malformed, so the UI still renders something
167    /// even if the v2 schema changes shape out from under us.
168    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    /// Full-text search via the v2 GraphQL endpoint, with a REST
199    /// fallback mirroring `browse_feed_gql`.
200    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    /// Collections browse / search via the v2 GraphQL endpoint. Falls
235    /// back to the REST `search_collections` path.
236    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    /// Fetch raw bytes from a URL, reusing the client + apikey header.
267    ///
268    /// Used for downloading thumbnail / gallery images referenced by the v1 API.
269    /// The apikey header is harmless on image CDN URLs (ignored by the CDN),
270    /// but keeping it here means one code path with consistent auth.
271    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    /// Fetch the full image gallery for a mod via the unofficial v2 GraphQL
283    /// endpoint. Returns a list of image URLs (the main picture_url will
284    /// typically be the first entry, but this is not guaranteed — the caller
285    /// should merge with picture_url as a fallback).
286    ///
287    /// The GraphQL schema is undocumented and may change; on any error this
288    /// function returns an `Err` and the caller should fall back to the
289    /// single `picture_url` from the v1 `get_mod` response.
290    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    /// Get files for a mod.
333    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    /// Search mods by query string.
339    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    /// Get trending mods for a game.
352    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    /// Get recently updated mods. Period must be `"1d"`, `"1w"`, or `"1m"`.
358    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    /// Search collections for a game.
368    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    /// Get a specific collection by slug.
380    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    /// Get a specific revision of a collection.
390    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    /// Discover a collection's game domain (and latest revision) by slug alone.
403    ///
404    /// Step 1 of the two-step collection install flow.
405    pub async fn get_collection_meta(&self, slug: &str) -> Result<NexusCollectionMeta> {
406        // The collections endpoint accepts a slug without game_domain:
407        //   GET /v1/collections/{slug}.json
408        let url = format!("{BASE_URL}/collections/{slug}.json");
409        self.get(&url).await
410    }
411
412    /// Endorse a mod on Nexus.
413    ///
414    /// The v1 endpoint requires a `Version` form parameter — passing the
415    /// installed mod version lets Nexus reject endorsements of obsolete
416    /// installs. Callers should pass the version string from the currently
417    /// loaded `NexusMod` response (not the local install, which may be
418    /// stale).
419    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    /// Abstain from endorsing (won't be asked again).
437    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    /// Fetch the full list of mods the current user is tracking, across all
455    /// games. The v1 endpoint is not filterable by domain, so callers that
456    /// only care about one mod should filter the returned list themselves.
457    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    /// Track a mod (receive Nexus notifications).
463    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    /// Stop tracking a mod.
479    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    /// Fetch a collection manifest, discovering the game domain automatically.
492    ///
493    /// If `version` is `Some`, that revision number is used directly.
494    /// Otherwise the latest published revision is queried first (two-step fetch).
495    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                // Still need the game domain; do step-1 but skip revision lookup
503                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}