use anyhow::{Result, bail};
use modde_core::manifest::collection::CollectionManifest;
use modde_core::{NexusFileId, NexusModId};
use reqwest::Client;
use serde::Deserialize;
use tracing::warn;
pub struct NexusApi {
client: Client,
api_key: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct NexusMod {
pub mod_id: NexusModId,
pub name: String,
pub summary: Option<String>,
pub version: String,
pub author: String,
#[serde(default)]
pub picture_url: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub domain_name: Option<String>,
#[serde(default)]
pub endorsement: Option<NexusEndorsement>,
#[serde(default)]
pub endorsement_count: u64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct NexusEndorsement {
pub endorse_status: String,
#[serde(default)]
pub timestamp: Option<u64>,
#[serde(default)]
pub version: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct NexusTrackedMod {
pub mod_id: NexusModId,
pub domain_name: String,
}
#[derive(Debug, Deserialize)]
pub struct NexusModFile {
pub file_id: NexusFileId,
pub name: String,
pub version: Option<String>,
pub size_kb: Option<u64>,
pub file_name: String,
#[serde(default)]
pub category_name: Option<String>,
#[serde(default)]
pub uploaded_timestamp: Option<u64>,
}
#[derive(Debug, Deserialize)]
pub struct NexusCollectionMeta {
pub game: NexusCollectionGame,
#[serde(default)]
pub latest_published_revision: Option<NexusCollectionRevision>,
}
#[derive(Debug, Deserialize)]
pub struct NexusCollectionGame {
pub domain_name: String,
}
#[derive(Debug, Deserialize)]
pub struct NexusCollectionRevision {
pub revision_number: u64,
}
#[derive(Debug, Deserialize)]
pub struct NexusModFiles {
pub files: Vec<NexusModFile>,
}
#[derive(Debug, Deserialize)]
pub struct NexusSearchResults {
pub results: Vec<NexusMod>,
pub total: u64,
}
#[derive(Debug, Deserialize)]
pub struct NexusUpdatedMod {
pub mod_id: NexusModId,
pub latest_file_update: u64,
pub latest_mod_activity: u64,
}
impl NexusApi {
#[must_use]
pub fn new(client: Client, api_key: String) -> Self {
Self { client, api_key }
}
async fn get<T: serde::de::DeserializeOwned>(&self, url: &str) -> Result<T> {
let resp = self
.client
.get(url)
.header("apikey", &self.api_key)
.send()
.await?;
if let Some(remaining) = resp.headers().get("x-rl-hourly-remaining")
&& let Ok(val) = remaining.to_str().unwrap_or("").parse::<u32>()
&& val < 10
{
warn!(remaining = val, "Nexus API hourly rate limit running low");
}
if resp.status() == 429 {
bail!("Nexus API rate limit exceeded. Please wait before retrying.");
}
let body = resp.error_for_status()?.json().await?;
Ok(body)
}
async fn delete_req(&self, url: &str, form: &[(&str, &str)]) -> Result<()> {
self.client
.delete(url)
.header("apikey", &self.api_key)
.form(form)
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn get_mod(&self, game_domain: &str, mod_id: NexusModId) -> Result<NexusMod> {
let url = format!(
"{}/games/{game_domain}/mods/{mod_id}.json",
super::base_url()
);
self.get(&url).await
}
pub async fn browse_feed_gql(
&self,
game_domain: &str,
kind: super::graphql::ModFeedKind,
) -> Result<Vec<super::graphql::GqlModTile>> {
match super::graphql::browse_feed(&self.client, &self.api_key, game_domain, kind).await {
Ok(tiles) => Ok(tiles),
Err(e) => {
warn!(error = %e, "GraphQL browse feed failed, falling back to REST");
let mods = self.trending_mods(game_domain).await?;
Ok(mods
.into_iter()
.map(|m| super::graphql::GqlModTile {
mod_id: m.mod_id,
name: m.name,
summary: m.summary,
version: Some(m.version),
author: Some(m.author),
picture_url: m.picture_url.clone(),
thumbnail_url: m.picture_url,
endorsements: Some(m.endorsement_count),
downloads: None,
uploaded_at: None,
game_domain: m.domain_name,
})
.collect())
}
}
}
pub async fn search_mods_gql(
&self,
game_domain: &str,
term: &str,
page: u32,
) -> Result<Vec<super::graphql::GqlModTile>> {
match super::graphql::search_mods(&self.client, &self.api_key, game_domain, term, page)
.await
{
Ok(tiles) => Ok(tiles),
Err(e) => {
warn!(error = %e, "GraphQL search failed, falling back to REST");
let results = self.search_mods(game_domain, term, page).await?;
Ok(results
.results
.into_iter()
.map(|m| super::graphql::GqlModTile {
mod_id: m.mod_id,
name: m.name,
summary: m.summary,
version: Some(m.version),
author: Some(m.author),
picture_url: m.picture_url.clone(),
thumbnail_url: m.picture_url,
endorsements: Some(m.endorsement_count),
downloads: None,
uploaded_at: None,
game_domain: m.domain_name,
})
.collect())
}
}
}
pub async fn collections_feed_gql(
&self,
game_domain: &str,
term: Option<&str>,
) -> Result<Vec<super::graphql::GqlCollectionTile>> {
match super::graphql::collections_feed(&self.client, &self.api_key, game_domain, term).await
{
Ok(tiles) => Ok(tiles),
Err(e) => {
warn!(error = %e, "GraphQL collections feed failed, falling back to REST");
let results = self
.search_collections(game_domain, term.unwrap_or(""))
.await?;
Ok(results
.into_iter()
.map(|c| super::graphql::GqlCollectionTile {
slug: c.slug,
name: c.name,
summary: c.summary,
tile_image: c.image_url,
game_domain: Some(c.game.domain_name),
endorsements: Some(c.endorsements),
downloads: None,
})
.collect())
}
}
}
pub async fn fetch_bytes(&self, url: &str) -> Result<Vec<u8>> {
let resp = self
.client
.get(url)
.header("apikey", &self.api_key)
.send()
.await?
.error_for_status()?;
Ok(resp.bytes().await?.to_vec())
}
pub async fn get_mod_media(
&self,
game_domain: &str,
mod_id: NexusModId,
) -> Result<Vec<String>> {
let query = r"query ModMedia($modId: Int!, $gameDomain: String!) {
mod(modId: $modId, gameDomain: $gameDomain) {
modImages { url }
}
}";
let body = serde_json::json!({
"query": query,
"variables": {
"modId": mod_id.get(),
"gameDomain": game_domain,
},
});
let resp = self
.client
.post(super::graphql_url())
.header("apikey", &self.api_key)
.header("content-type", "application/json")
.json(&body)
.send()
.await?
.error_for_status()?;
let payload: serde_json::Value = resp.json().await?;
if let Some(errors) = payload.get("errors") {
bail!("Nexus GraphQL errors: {errors}");
}
let images = payload
.get("data")
.and_then(|d| d.get("mod"))
.and_then(|m| m.get("modImages"))
.and_then(|a| a.as_array())
.ok_or_else(|| anyhow::anyhow!("unexpected GraphQL response shape"))?;
let urls: Vec<String> = images
.iter()
.filter_map(|img| {
img.get("url")
.and_then(|u| u.as_str())
.map(std::string::ToString::to_string)
})
.collect();
Ok(urls)
}
pub async fn get_mod_files(
&self,
game_domain: &str,
mod_id: NexusModId,
) -> Result<NexusModFiles> {
let url = format!(
"{}/games/{game_domain}/mods/{mod_id}/files.json",
super::base_url()
);
self.get(&url).await
}
pub async fn search_mods(
&self,
game_domain: &str,
query: &str,
page: u32,
) -> Result<NexusSearchResults> {
let url = format!(
"{}/games/{game_domain}/mods/search.json?search={query}&page={page}",
super::base_url()
);
self.get(&url).await
}
pub async fn trending_mods(&self, game_domain: &str) -> Result<Vec<NexusMod>> {
let url = format!(
"{}/games/{game_domain}/mods/trending.json",
super::base_url()
);
self.get(&url).await
}
pub async fn updated_mods(
&self,
game_domain: &str,
period: &str,
) -> Result<Vec<NexusUpdatedMod>> {
let url = format!(
"{}/games/{game_domain}/mods/updated.json?period={period}",
super::base_url()
);
self.get(&url).await
}
pub async fn search_collections(
&self,
game_domain: &str,
query: &str,
) -> Result<Vec<CollectionManifest>> {
let url = format!(
"{}/games/{game_domain}/collections.json?search={query}",
super::base_url()
);
self.get(&url).await
}
pub async fn get_collection(
&self,
game_domain: &str,
slug: &str,
) -> Result<CollectionManifest> {
let url = format!(
"{}/games/{game_domain}/collections/{slug}.json",
super::base_url()
);
self.get(&url).await
}
pub async fn get_collection_revision(
&self,
game_domain: &str,
slug: &str,
revision: u64,
) -> Result<CollectionManifest> {
let url = format!(
"{}/games/{game_domain}/collections/{slug}/revisions/{revision}.json",
super::base_url()
);
self.get(&url).await
}
pub async fn get_collection_meta(&self, slug: &str) -> Result<NexusCollectionMeta> {
let url = format!("{}/collections/{slug}.json", super::base_url());
self.get(&url).await
}
pub async fn endorse_mod(
&self,
game_domain: &str,
mod_id: NexusModId,
version: &str,
) -> Result<()> {
let url = format!(
"{}/games/{game_domain}/mods/{mod_id}/endorse.json",
super::base_url()
);
self.client
.post(&url)
.header("apikey", &self.api_key)
.form(&[("Version", version)])
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn abstain_mod(
&self,
game_domain: &str,
mod_id: NexusModId,
version: &str,
) -> Result<()> {
let url = format!(
"{}/games/{game_domain}/mods/{mod_id}/abstain.json",
super::base_url()
);
self.client
.post(&url)
.header("apikey", &self.api_key)
.form(&[("Version", version)])
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn get_tracked_mods(&self) -> Result<Vec<NexusTrackedMod>> {
let url = format!("{}/user/tracked_mods.json", super::base_url());
self.get(&url).await
}
pub async fn track_mod(&self, game_domain: &str, mod_id: NexusModId) -> Result<()> {
let url = format!("{}/user/tracked_mods.json", super::base_url());
self.client
.post(&url)
.header("apikey", &self.api_key)
.form(&[
("domain_name", game_domain),
("mod_id", &mod_id.to_string()),
])
.send()
.await?
.error_for_status()?;
Ok(())
}
pub async fn untrack_mod(&self, game_domain: &str, mod_id: NexusModId) -> Result<()> {
let url = format!("{}/user/tracked_mods.json", super::base_url());
self.delete_req(
&url,
&[
("domain_name", game_domain),
("mod_id", &mod_id.to_string()),
],
)
.await
}
pub async fn get_collection_by_slug(
&self,
slug: &str,
version: Option<u64>,
) -> Result<CollectionManifest> {
let (game_domain, revision) = if let Some(rev) = version {
let meta = self.get_collection_meta(slug).await?;
(meta.game.domain_name, rev)
} else {
let meta = self.get_collection_meta(slug).await?;
let rev = meta
.latest_published_revision
.map(|r| r.revision_number)
.ok_or_else(|| anyhow::anyhow!("collection '{slug}' has no published revisions"))?;
(meta.game.domain_name, rev)
};
self.get_collection_revision(&game_domain, slug, revision)
.await
}
}