Skip to main content

modde_sources/nexus/
api.rs

1//! Typed Nexus Mods v1 REST API client and the response types it deserializes.
2
3use anyhow::{Result, bail};
4use modde_core::manifest::collection::CollectionManifest;
5use modde_core::{NexusFileId, NexusModId};
6use reqwest::Client;
7use serde::Deserialize;
8use tracing::warn;
9
10/// Typed Nexus API client.
11pub struct NexusApi {
12    client: Client,
13    api_key: String,
14}
15
16/// A mod's metadata as returned by the Nexus v1 mod endpoint.
17#[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    /// Primary thumbnail URL (full-size picture shown at the top of the mod page).
25    #[serde(default)]
26    pub picture_url: Option<String>,
27    /// Long-form HTML description. May contain BBCode-derived markup.
28    #[serde(default)]
29    pub description: Option<String>,
30    /// Nexus game domain the mod belongs to (e.g. `"skyrimspecialedition"`).
31    #[serde(default)]
32    pub domain_name: Option<String>,
33    /// The current user's endorsement relationship to this mod. Only
34    /// populated on authenticated requests. Absent otherwise.
35    #[serde(default)]
36    pub endorsement: Option<NexusEndorsement>,
37    /// Total endorsements the mod has received (not user-specific).
38    #[serde(default)]
39    pub endorsement_count: u64,
40}
41
42/// The current user's endorsement status for a mod.
43///
44/// `endorse_status` values returned by Nexus v1: `"Undecided"`, `"Abstained"`,
45/// `"Endorsed"`. See `node-nexus-api/lib/types.d.ts` (`EndorsedStatus`) for the
46/// canonical enum.
47#[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/// A single entry in the user's tracked-mods list.
57#[derive(Debug, Clone, Deserialize)]
58pub struct NexusTrackedMod {
59    pub mod_id: NexusModId,
60    pub domain_name: String,
61}
62
63/// Metadata for a single downloadable file attached to a mod.
64#[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    /// File category: `"MAIN"`, `"UPDATE"`, `"OPTIONAL"`, `"OLD_VERSION"`, `"MISCELLANEOUS"`.
72    #[serde(default)]
73    pub category_name: Option<String>,
74    /// Upload timestamp (Unix epoch seconds). Used to pick the most-recent MAIN file.
75    #[serde(default)]
76    pub uploaded_timestamp: Option<u64>,
77}
78
79/// Minimal collection metadata returned by the slug-based lookup endpoint.
80///
81/// Used in the two-step collection install flow to discover the game domain
82/// before fetching the full revision manifest.
83#[derive(Debug, Deserialize)]
84pub struct NexusCollectionMeta {
85    pub game: NexusCollectionGame,
86    #[serde(default)]
87    pub latest_published_revision: Option<NexusCollectionRevision>,
88}
89
90/// The game a collection belongs to.
91#[derive(Debug, Deserialize)]
92pub struct NexusCollectionGame {
93    pub domain_name: String,
94}
95
96/// A published revision of a collection.
97#[derive(Debug, Deserialize)]
98pub struct NexusCollectionRevision {
99    pub revision_number: u64,
100}
101
102/// The file listing for a mod.
103#[derive(Debug, Deserialize)]
104pub struct NexusModFiles {
105    pub files: Vec<NexusModFile>,
106}
107
108/// A page of mod search results plus the total match count.
109#[derive(Debug, Deserialize)]
110pub struct NexusSearchResults {
111    pub results: Vec<NexusMod>,
112    pub total: u64,
113}
114
115/// An entry from the "recently updated mods" feed.
116#[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    /// Create a client authenticated with the given `api_key`.
125    #[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        // Check rate limit headers
139        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    /// Get mod details.
166    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    // ── GraphQL v2 browse helpers ─────────────────────────────
175
176    /// Fetch a trending or monthly-top browse feed via the v2 GraphQL
177    /// endpoint. Falls back to the REST `trending_mods` path when the
178    /// GraphQL response is malformed, so the UI still renders something
179    /// even if the v2 schema changes shape out from under us.
180    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    /// Full-text search via the v2 GraphQL endpoint, with a REST
211    /// fallback mirroring `browse_feed_gql`.
212    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    /// Collections browse / search via the v2 GraphQL endpoint. Falls
247    /// back to the REST `search_collections` path.
248    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    /// Fetch raw bytes from a URL, reusing the client + apikey header.
278    ///
279    /// Used for downloading thumbnail / gallery images referenced by the v1 API.
280    /// The apikey header is harmless on image CDN URLs (ignored by the CDN),
281    /// but keeping it here means one code path with consistent auth.
282    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    /// Fetch the full image gallery for a mod via the unofficial v2 GraphQL
294    /// endpoint. Returns a list of image URLs (the main `picture_url` will
295    /// typically be the first entry, but this is not guaranteed — the caller
296    /// should merge with `picture_url` as a fallback).
297    ///
298    /// The GraphQL schema is undocumented and may change; on any error this
299    /// function returns an `Err` and the caller should fall back to the
300    /// single `picture_url` from the v1 `get_mod` response.
301    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    /// Get files for a mod.
352    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    /// Search mods by query string.
365    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    /// Get trending mods for a game.
379    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    /// Get recently updated mods. Period must be `"1d"`, `"1w"`, or `"1m"`.
388    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    /// Search collections for a game.
401    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    /// Get a specific collection by slug.
414    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    /// Get a specific revision of a collection.
427    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    /// Discover a collection's game domain (and latest revision) by slug alone.
441    ///
442    /// Step 1 of the two-step collection install flow.
443    pub async fn get_collection_meta(&self, slug: &str) -> Result<NexusCollectionMeta> {
444        // The collections endpoint accepts a slug without game_domain:
445        //   GET /v1/collections/{slug}.json
446        let url = format!("{}/collections/{slug}.json", super::base_url());
447        self.get(&url).await
448    }
449
450    /// Endorse a mod on Nexus.
451    ///
452    /// The v1 endpoint requires a `Version` form parameter — passing the
453    /// installed mod version lets Nexus reject endorsements of obsolete
454    /// installs. Callers should pass the version string from the currently
455    /// loaded `NexusMod` response (not the local install, which may be
456    /// stale).
457    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    /// Abstain from endorsing (won't be asked again).
478    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    /// Fetch the full list of mods the current user is tracking, across all
499    /// games. The v1 endpoint is not filterable by domain, so callers that
500    /// only care about one mod should filter the returned list themselves.
501    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    /// Track a mod (receive Nexus notifications).
507    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    /// Stop tracking a mod.
523    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    /// Fetch a collection manifest, discovering the game domain automatically.
536    ///
537    /// If `version` is `Some`, that revision number is used directly.
538    /// Otherwise the latest published revision is queried first (two-step fetch).
539    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            // Still need the game domain; do step-1 but skip revision lookup
546            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}