use serde::{Deserialize, Serialize};
use crate::error::Result;
use crate::mods::detector::ModLoader;
const MR_BASE: &str = "https://api.modrinth.com/v2";
const USER_AGENT: &str = concat!("hexomc-lib/", env!("CARGO_PKG_VERSION"));
pub struct ModrinthClient {
client: reqwest::Client,
}
impl ModrinthClient {
pub fn new() -> Self {
let client = reqwest::Client::builder()
.user_agent(USER_AGENT)
.build()
.unwrap_or_default();
Self { client }
}
}
impl Default for ModrinthClient {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct MrProject {
#[serde(alias = "project_id")]
pub id: String,
pub slug: String,
pub title: String,
pub description: String,
pub downloads: u64,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct MrVersion {
pub id: String,
pub project_id: String,
pub name: String,
pub version_number: String,
pub game_versions: Vec<String>,
pub loaders: Vec<String>,
pub date_published: String,
pub files: Vec<MrFile>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct MrFile {
pub hashes: MrHashes,
pub url: String,
pub filename: String,
pub primary: bool,
pub size: u64,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct MrHashes {
pub sha1: String,
pub sha512: String,
}
impl MrVersion {
pub fn primary_file(&self) -> Option<&MrFile> {
self.files
.iter()
.find(|f| f.primary)
.or_else(|| self.files.first())
}
pub fn sha1(&self) -> Option<&str> {
self.primary_file().map(|f| f.hashes.sha1.as_str())
}
pub fn download_url(&self) -> Option<&str> {
self.primary_file().map(|f| f.url.as_str())
}
pub fn file_name(&self) -> Option<&str> {
self.primary_file().map(|f| f.filename.as_str())
}
}
fn loader_tag(loader: &ModLoader) -> &'static str {
match loader {
ModLoader::Forge => "forge",
ModLoader::Fabric => "fabric",
ModLoader::Quilt => "quilt",
ModLoader::NeoForge => "neoforge",
ModLoader::Unknown => "",
}
}
impl ModrinthClient {
pub async fn search_mod(&self, name: &str, loader: &ModLoader) -> Result<Vec<MrProject>> {
#[derive(Deserialize)]
struct Resp {
hits: Vec<MrProject>,
}
let tag = loader_tag(loader);
let facets = if tag.is_empty() {
"[[\"project_type:mod\"]]".to_string()
} else {
format!("[[\"project_type:mod\"],[\"categories:{}\"]]", tag)
};
let resp: Resp = self
.client
.get(format!("{}/search", MR_BASE))
.query(&[("query", name), ("facets", &facets), ("limit", "20")])
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp.hits)
}
pub async fn get_project_versions(
&self,
id: &str,
mc_version: &str,
loader: &ModLoader,
) -> Result<Vec<MrVersion>> {
let mut query: Vec<(&str, String)> =
vec![("game_versions", format!("[\"{}\"]", mc_version))];
let tag = loader_tag(loader);
if !tag.is_empty() {
query.push(("loaders", format!("[\"{}\"]", tag)));
}
let versions: Vec<MrVersion> = self
.client
.get(format!("{}/project/{}/version", MR_BASE, id))
.query(&query)
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(versions)
}
pub async fn get_latest_version(
&self,
id: &str,
mc_version: &str,
loader: &ModLoader,
) -> Result<Option<MrVersion>> {
let versions = self.get_project_versions(id, mc_version, loader).await?;
Ok(versions
.into_iter()
.max_by(|a, b| a.date_published.cmp(&b.date_published)))
}
pub async fn get_version_by_hash(&self, sha1: &str) -> Result<Option<MrVersion>> {
let resp = self
.client
.get(format!("{}/version_file/{}", MR_BASE, sha1))
.query(&[("algorithm", "sha1")])
.send()
.await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
let version: MrVersion = resp.error_for_status()?.json().await?;
Ok(Some(version))
}
pub async fn get_latest_by_hash(
&self,
sha1: &str,
mc_version: &str,
loader: &ModLoader,
) -> Result<Option<MrVersion>> {
#[derive(Serialize)]
struct Body {
loaders: Vec<String>,
game_versions: Vec<String>,
}
let tag = loader_tag(loader);
let loaders = if tag.is_empty() {
Vec::new()
} else {
vec![tag.to_string()]
};
let resp = self
.client
.post(format!("{}/version_file/{}/update", MR_BASE, sha1))
.query(&[("algorithm", "sha1")])
.json(&Body {
loaders,
game_versions: vec![mc_version.to_string()],
})
.send()
.await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
let version: MrVersion = resp.error_for_status()?.json().await?;
Ok(Some(version))
}
}