initial commit
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ife1391ed23a1e7f388b1b5eca90b9ea76a6a6964
This commit is contained in:
commit
ef28bdaeb4
63 changed files with 17292 additions and 0 deletions
383
src/platform/curseforge.rs
Normal file
383
src/platform/curseforge.rs
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::traits::PlatformClient;
|
||||
use crate::{
|
||||
error::{PakkerError, Result},
|
||||
model::{Project, ProjectFile, ProjectSide, ProjectType, ReleaseType},
|
||||
utils::generate_pakku_id,
|
||||
};
|
||||
|
||||
const CURSEFORGE_API_BASE: &str = "https://api.curseforge.com/v1";
|
||||
const LOADER_VERSION_TYPE_ID: i32 = 68441;
|
||||
|
||||
pub struct CurseForgePlatform {
|
||||
client: Client,
|
||||
api_key: Option<String>,
|
||||
}
|
||||
|
||||
impl CurseForgePlatform {
|
||||
pub fn new(api_key: Option<String>) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
api_key,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_headers(&self) -> Result<reqwest::header::HeaderMap> {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
|
||||
if let Some(api_key) = &self.api_key {
|
||||
headers.insert(
|
||||
"x-api-key",
|
||||
reqwest::header::HeaderValue::from_str(api_key).map_err(|_| {
|
||||
PakkerError::ConfigError("Invalid API key".to_string())
|
||||
})?,
|
||||
);
|
||||
} else {
|
||||
return Err(PakkerError::ConfigError(
|
||||
"CurseForge API key required".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
const fn map_class_id(class_id: u32) -> ProjectType {
|
||||
match class_id {
|
||||
6 => ProjectType::Mod,
|
||||
12 => ProjectType::ResourcePack,
|
||||
6945 => ProjectType::DataPack,
|
||||
6552 => ProjectType::Shader,
|
||||
17 => ProjectType::World,
|
||||
_ => ProjectType::Mod,
|
||||
}
|
||||
}
|
||||
|
||||
const fn map_release_type(release_type: u32) -> ReleaseType {
|
||||
match release_type {
|
||||
1 => ReleaseType::Release,
|
||||
2 => ReleaseType::Beta,
|
||||
3 => ReleaseType::Alpha,
|
||||
_ => ReleaseType::Release,
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_project(&self, cf_project: CurseForgeProject) -> Project {
|
||||
let pakku_id = generate_pakku_id();
|
||||
let project_type = Self::map_class_id(cf_project.class_id.unwrap_or(6));
|
||||
|
||||
let mut project = Project::new(pakku_id, project_type, ProjectSide::Both);
|
||||
|
||||
project.add_platform(
|
||||
"curseforge".to_string(),
|
||||
cf_project.id.to_string(),
|
||||
cf_project.slug.clone(),
|
||||
cf_project.name,
|
||||
);
|
||||
|
||||
project.redistributable = false;
|
||||
project
|
||||
}
|
||||
|
||||
fn convert_file(
|
||||
&self,
|
||||
cf_file: CurseForgeFile,
|
||||
project_id: &str,
|
||||
) -> ProjectFile {
|
||||
let mut hashes = HashMap::new();
|
||||
|
||||
for hash in cf_file.hashes {
|
||||
hashes.insert(hash.algo.to_lowercase(), hash.value.clone());
|
||||
}
|
||||
|
||||
let mc_versions: Vec<String> = cf_file.game_versions.clone();
|
||||
|
||||
// Extract loaders from sortableGameVersions with LOADER_VERSION_TYPE_ID
|
||||
let loaders: Vec<String> = cf_file
|
||||
.sortable_game_versions
|
||||
.iter()
|
||||
.filter(|v| v.game_version_type_id == Some(LOADER_VERSION_TYPE_ID))
|
||||
.map(|v| v.game_version_name.to_lowercase())
|
||||
.collect();
|
||||
|
||||
ProjectFile {
|
||||
file_type: "mod".to_string(),
|
||||
file_name: cf_file.file_name.clone(),
|
||||
mc_versions,
|
||||
loaders,
|
||||
release_type: Self::map_release_type(cf_file.release_type.unwrap_or(1)),
|
||||
url: cf_file.download_url.clone().unwrap_or_else(|| {
|
||||
format!(
|
||||
"https://edge.forgecdn.net/files/{}/{}/{}",
|
||||
cf_file.id / 1000,
|
||||
cf_file.id % 1000,
|
||||
cf_file.file_name
|
||||
)
|
||||
}),
|
||||
id: cf_file.id.to_string(),
|
||||
parent_id: project_id.to_string(),
|
||||
hashes,
|
||||
required_dependencies: cf_file
|
||||
.dependencies
|
||||
.iter()
|
||||
.filter(|d| d.relation_type == 3)
|
||||
.map(|d| d.mod_id.to_string())
|
||||
.collect(),
|
||||
size: cf_file.file_length,
|
||||
date_published: cf_file.file_date.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn search_project_by_slug(
|
||||
&self,
|
||||
slug: &str,
|
||||
) -> Result<CurseForgeProject> {
|
||||
let url =
|
||||
format!("{CURSEFORGE_API_BASE}/mods/search?gameId=432&slug={slug}");
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.headers(self.get_headers()?)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(PakkerError::ProjectNotFound(slug.to_string()));
|
||||
}
|
||||
|
||||
let result: CurseForgeSearchResponse = response.json().await?;
|
||||
|
||||
result
|
||||
.data
|
||||
.into_iter()
|
||||
.find(|p| p.slug == slug)
|
||||
.ok_or_else(|| PakkerError::ProjectNotFound(slug.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PlatformClient for CurseForgePlatform {
|
||||
async fn request_project(
|
||||
&self,
|
||||
identifier: &str,
|
||||
_mc_versions: &[String],
|
||||
_loaders: &[String],
|
||||
) -> Result<Project> {
|
||||
if let Ok(mod_id) = identifier.parse::<u32>() {
|
||||
let url = format!("{CURSEFORGE_API_BASE}/mods/{mod_id}");
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.headers(self.get_headers()?)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let result: CurseForgeProjectResponse = response.json().await?;
|
||||
return Ok(self.convert_project(result.data));
|
||||
}
|
||||
}
|
||||
|
||||
let cf_project = self.search_project_by_slug(identifier).await?;
|
||||
Ok(self.convert_project(cf_project))
|
||||
}
|
||||
|
||||
async fn request_project_files(
|
||||
&self,
|
||||
project_id: &str,
|
||||
mc_versions: &[String],
|
||||
loaders: &[String],
|
||||
) -> Result<Vec<ProjectFile>> {
|
||||
let mut url = format!("{CURSEFORGE_API_BASE}/mods/{project_id}/files");
|
||||
|
||||
// Add query parameters for server-side filtering (Pakku-compatible)
|
||||
let mut query_params = Vec::new();
|
||||
|
||||
// Add gameVersionTypeId for each MC version (requires lookup)
|
||||
if !mc_versions.is_empty() {
|
||||
// Fetch game version type IDs
|
||||
// Add MC version gameVersionTypeId = 73250 for Minecraft versions
|
||||
for mc_version in mc_versions {
|
||||
query_params.push(("gameVersion", mc_version.clone()));
|
||||
}
|
||||
query_params.push(("gameVersionTypeId", "73250".to_string()));
|
||||
}
|
||||
|
||||
// Add mod loader types
|
||||
if !loaders.is_empty() {
|
||||
let loader_str = loaders.join(",");
|
||||
query_params.push(("modLoaderTypes", loader_str));
|
||||
}
|
||||
|
||||
if !query_params.is_empty() {
|
||||
let query_string = query_params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k}={v}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
url = format!("{url}?{query_string}");
|
||||
}
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.headers(self.get_headers()?)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(PakkerError::ProjectNotFound(project_id.to_string()));
|
||||
}
|
||||
|
||||
let result: CurseForgeFilesResponse = response.json().await?;
|
||||
|
||||
let files: Vec<ProjectFile> = result
|
||||
.data
|
||||
.into_iter()
|
||||
.map(|f| self.convert_file(f, project_id))
|
||||
.collect();
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
async fn request_project_with_files(
|
||||
&self,
|
||||
identifier: &str,
|
||||
mc_versions: &[String],
|
||||
loaders: &[String],
|
||||
) -> Result<Project> {
|
||||
let mut project = self
|
||||
.request_project(identifier, mc_versions, loaders)
|
||||
.await?;
|
||||
let project_id = project
|
||||
.get_platform_id("curseforge")
|
||||
.ok_or_else(|| {
|
||||
PakkerError::InternalError("Missing curseforge ID".to_string())
|
||||
})?
|
||||
.clone();
|
||||
|
||||
let files = self
|
||||
.request_project_files(&project_id, mc_versions, loaders)
|
||||
.await?;
|
||||
project.files = files;
|
||||
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
async fn lookup_by_hash(&self, hash: &str) -> Result<Option<Project>> {
|
||||
// CurseForge uses Murmur2 hash for file fingerprints
|
||||
let fingerprint = hash
|
||||
.parse::<u32>()
|
||||
.map_err(|_| PakkerError::InvalidHash(hash.to_string()))?;
|
||||
|
||||
let url = format!("{CURSEFORGE_API_BASE}/fingerprints");
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.headers(self.get_headers()?)
|
||||
.json(&serde_json::json!({
|
||||
"fingerprints": [fingerprint]
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let response_data: serde_json::Value = response.json().await?;
|
||||
|
||||
if let Some(matches) = response_data["data"]["exactMatches"].as_array()
|
||||
&& let Some(first_match) = matches.first()
|
||||
&& let Some(file) = first_match["file"].as_object()
|
||||
{
|
||||
let mod_id = file["modId"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| {
|
||||
PakkerError::InvalidResponse("Missing modId".to_string())
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
return self
|
||||
.request_project_with_files(&mod_id, &[], &[])
|
||||
.await
|
||||
.map(Some);
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
// CurseForge API models
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
struct CurseForgeProject {
|
||||
id: u32,
|
||||
name: String,
|
||||
slug: String,
|
||||
#[serde(rename = "classId")]
|
||||
class_id: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
struct CurseForgeGameVersion {
|
||||
#[serde(rename = "gameVersionName")]
|
||||
game_version_name: String,
|
||||
#[serde(rename = "gameVersionTypeId")]
|
||||
game_version_type_id: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
struct CurseForgeHash {
|
||||
algo: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
struct CurseForgeDependency {
|
||||
#[serde(rename = "modId")]
|
||||
mod_id: u32,
|
||||
#[serde(rename = "relationType")]
|
||||
relation_type: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
struct CurseForgeProjectResponse {
|
||||
data: CurseForgeProject,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
struct CurseForgeFile {
|
||||
id: u32,
|
||||
#[serde(rename = "fileName")]
|
||||
file_name: String,
|
||||
#[serde(rename = "downloadUrl")]
|
||||
download_url: Option<String>,
|
||||
#[serde(rename = "gameVersions")]
|
||||
game_versions: Vec<String>,
|
||||
#[serde(rename = "sortableGameVersions")]
|
||||
sortable_game_versions: Vec<CurseForgeGameVersion>,
|
||||
#[serde(rename = "releaseType")]
|
||||
release_type: Option<u32>,
|
||||
#[serde(rename = "fileLength")]
|
||||
file_length: u64,
|
||||
#[serde(rename = "fileDate")]
|
||||
file_date: String,
|
||||
hashes: Vec<CurseForgeHash>,
|
||||
dependencies: Vec<CurseForgeDependency>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
struct CurseForgeFilesResponse {
|
||||
data: Vec<CurseForgeFile>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
struct CurseForgeSearchResponse {
|
||||
data: Vec<CurseForgeProject>,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue