use std::sync::Arc;
use std::time::Duration;
use once_cell::sync::Lazy;
use lighty_core::hosts::HTTP_CLIENT as CLIENT;
use lighty_loaders::types::version_metadata::Mods;
use lighty_loaders::types::Loader;
use lighty_core::QueryError;
use lighty_loaders::utils::cache::Cache;
use super::api::{read_api_key, url_encode, BASE_URL, PROVIDER};
use super::client_metadata::{
CurseForgeDependency, CurseForgeEnvelope, CurseForgeFile, CurseForgeMod, CLASS_MOD,
CLASS_RESOURCE_PACK, CLASS_SHADER_PACK, DEP_REQUIRED, HASH_ALGO_SHA1, MOD_LOADER_FABRIC,
MOD_LOADER_FORGE, MOD_LOADER_NEOFORGE, MOD_LOADER_QUILT,
};
use crate::request::ModRequest;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CurseForgeKey {
pub mod_id: u32,
pub pin: Option<u32>,
pub mc: String,
pub loader: Loader,
}
pub static CURSEFORGE_CACHE: Lazy<Cache<CurseForgeKey, Arc<(Mods, Vec<ModRequest>)>>> =
Lazy::new(Cache::with_smart_cleanup);
static CLASS_ID_CACHE: Lazy<Cache<u32, Arc<u32>>> = Lazy::new(Cache::with_smart_cleanup);
pub async fn fetch(
request: &ModRequest,
minecraft_version: &str,
loader: &Loader,
ttl: Duration,
) -> Result<(Mods, Vec<ModRequest>), QueryError> {
let (mod_id, pinned_file) = match request {
ModRequest::CurseForge { mod_id, file_id } => (*mod_id, *file_id),
_ => unreachable!("curseforge::fetch called with a non-CurseForge request"),
};
let key = CurseForgeKey {
mod_id,
pin: pinned_file,
mc: minecraft_version.to_string(),
loader: loader.clone(),
};
let loader_for_fetch = loader.clone();
let cached = CURSEFORGE_CACHE
.get_or_try_insert_with(key, ttl, || async move {
let api_key = read_api_key()?;
let subdir = install_subdir(class_id(mod_id, &api_key, ttl).await?)?;
let file = if let Some(file_id) = pinned_file {
fetch_pinned_file_with_key(mod_id, file_id, &api_key).await?
} else {
let mod_loader_code = mod_loader_code(&loader_for_fetch)?;
fetch_latest_compatible(mod_id, minecraft_version, mod_loader_code, &api_key)
.await?
};
let pivot = into_pivot(&file, subdir)?;
let dependencies = collect_required_dependencies(&file);
Ok::<_, QueryError>(Arc::new((pivot, dependencies)))
})
.await?;
Ok((*cached).clone())
}
fn mod_loader_code(loader: &Loader) -> Result<u8, QueryError> {
match loader {
Loader::Fabric => Ok(MOD_LOADER_FABRIC),
Loader::Forge => Ok(MOD_LOADER_FORGE),
Loader::NeoForge => Ok(MOD_LOADER_NEOFORGE),
Loader::Quilt => Ok(MOD_LOADER_QUILT),
Loader::Vanilla | Loader::Optifine | Loader::LightyUpdater => {
Err(QueryError::UnsupportedLoader(format!(
"CurseForge doesn't host mods for {:?} instances",
loader
)))
}
}
}
async fn fetch_pinned_file_with_key(
mod_id: u32,
file_id: u32,
api_key: &str,
) -> Result<CurseForgeFile, QueryError> {
let url = format!("{}/mods/{}/files/{}", BASE_URL, mod_id, file_id);
let response = CLIENT.get(&url).header("x-api-key", api_key).send().await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(QueryError::ModNotFound {
provider: PROVIDER,
id: format!("{}/{}", mod_id, file_id),
});
}
let envelope = response
.error_for_status()?
.json::<CurseForgeEnvelope<CurseForgeFile>>()
.await?;
Ok(envelope.data)
}
pub async fn fetch_pinned_file(
mod_id: u32,
file_id: u32,
) -> Result<CurseForgeFile, QueryError> {
let api_key = read_api_key()?;
fetch_pinned_file_with_key(mod_id, file_id, &api_key).await
}
async fn fetch_latest_compatible(
mod_id: u32,
minecraft_version: &str,
mod_loader_code: u8,
api_key: &str,
) -> Result<CurseForgeFile, QueryError> {
let url = format!(
"{}/mods/{}/files?gameVersion={}&modLoaderType={}",
BASE_URL,
mod_id,
url_encode(minecraft_version),
mod_loader_code
);
let response = CLIENT.get(&url).header("x-api-key", api_key).send().await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(QueryError::ModNotFound {
provider: PROVIDER,
id: mod_id.to_string(),
});
}
let envelope = response
.error_for_status()?
.json::<CurseForgeEnvelope<Vec<CurseForgeFile>>>()
.await?;
envelope
.data
.into_iter()
.next()
.ok_or_else(|| QueryError::ModIncompatible {
provider: PROVIDER,
id: mod_id.to_string(),
mc: minecraft_version.to_string(),
loader: mod_loader_code.to_string(),
})
}
fn into_pivot(file: &CurseForgeFile, subdir: &str) -> Result<Mods, QueryError> {
let url = file
.download_url
.clone()
.ok_or_else(|| QueryError::ModDistributionForbidden {
id: file.mod_id.to_string(),
})?;
let sha1 = file
.hashes
.iter()
.find(|h| h.algo == HASH_ALGO_SHA1)
.map(|h| h.value.clone());
Ok(Mods {
name: format!("{}-{}", file.mod_id, file.id),
url: Some(url),
path: Some(format!("{}/{}", subdir, file.file_name)),
sha1,
size: (file.file_length > 0).then_some(file.file_length),
})
}
fn install_subdir(class_id: u32) -> Result<&'static str, QueryError> {
match class_id {
CLASS_MOD => Ok("mods"),
CLASS_RESOURCE_PACK => Ok("resourcepacks"),
CLASS_SHADER_PACK => Ok("shaderpacks"),
other => Err(QueryError::UnsupportedFormat {
what: "CurseForge classId".to_string(),
expected: format!(
"{} (mod) | {} (resourcepack) | {} (shader)",
CLASS_MOD, CLASS_RESOURCE_PACK, CLASS_SHADER_PACK
),
found: other.to_string(),
}),
}
}
pub(crate) async fn class_id(
mod_id: u32,
api_key: &str,
ttl: Duration,
) -> Result<u32, QueryError> {
let api_key = api_key.to_string();
let cached = CLASS_ID_CACHE
.get_or_try_insert_with(mod_id, ttl, || async move {
let url = format!("{}/mods/{}", BASE_URL, mod_id);
let response = CLIENT.get(&url).header("x-api-key", &api_key).send().await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(QueryError::ModNotFound {
provider: PROVIDER,
id: mod_id.to_string(),
});
}
let envelope = response
.error_for_status()?
.json::<CurseForgeEnvelope<CurseForgeMod>>()
.await?;
Ok::<_, QueryError>(Arc::new(envelope.data.class_id))
})
.await?;
Ok(*cached)
}
pub async fn install_subdir_for(mod_id: u32, ttl: Duration) -> Result<&'static str, QueryError> {
let api_key = read_api_key()?;
install_subdir(class_id(mod_id, &api_key, ttl).await?)
}
fn collect_required_dependencies(file: &CurseForgeFile) -> Vec<ModRequest> {
file.dependencies
.iter()
.filter(|dep| dep.relation_type == DEP_REQUIRED)
.map(modrequest_from)
.collect()
}
fn modrequest_from(dep: &CurseForgeDependency) -> ModRequest {
ModRequest::CurseForge {
mod_id: dep.mod_id,
file_id: None,
}
}