initial commit

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ife1391ed23a1e7f388b1b5eca90b9ea76a6a6964
This commit is contained in:
raf 2026-01-29 19:36:25 +03:00
commit ef28bdaeb4
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
63 changed files with 17292 additions and 0 deletions

383
src/platform/curseforge.rs Normal file
View 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>,
}