platform/multiplatform: add multiplatform client with cross-ref

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ie2cf48136e5a9017265a3b0ef26619356a6a6964
This commit is contained in:
raf 2026-04-18 22:18:27 +03:00
commit 5772200da9
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
6 changed files with 276 additions and 0 deletions

View file

@ -1,6 +1,7 @@
mod curseforge;
mod github;
mod modrinth;
mod multiplatform;
mod traits;
use std::sync::Arc;
@ -8,6 +9,7 @@ use std::sync::Arc;
pub use curseforge::CurseForgePlatform;
pub use github::GitHubPlatform;
pub use modrinth::ModrinthPlatform;
pub use multiplatform::MultiplatformPlatform;
pub use traits::PlatformClient;
use crate::{error::Result, http, rate_limiter::RateLimiter};
@ -55,6 +57,14 @@ fn create_client(
api_key,
)))
},
"multiplatform" => {
let cf = CurseForgePlatform::with_client(get_http_client(), api_key);
let mr = ModrinthPlatform::with_client(get_http_client());
Ok(Box::new(MultiplatformPlatform::new(
Arc::new(cf),
Arc::new(mr),
)))
},
_ => {
Err(crate::error::PakkerError::ConfigError(format!(
"Unknown platform: {platform}"
@ -117,4 +127,12 @@ impl PlatformClient for RateLimitedPlatform {
self.rate_limiter.wait_for(&self.platform_name).await;
self.platform.lookup_by_hash(hash).await
}
async fn request_project_from_slug(
&self,
slug: &str,
) -> Result<Option<crate::model::Project>> {
self.rate_limiter.wait_for(&self.platform_name).await;
self.platform.request_project_from_slug(slug).await
}
}

View file

@ -391,6 +391,18 @@ impl PlatformClient for CurseForgePlatform {
Ok(None)
}
async fn request_project_from_slug(
&self,
slug: &str,
) -> Result<Option<Project>> {
// Try to fetch project by slug using search API
match self.search_project_by_slug(slug).await {
Ok(cf_project) => Ok(Some(self.convert_project(cf_project))),
Err(PakkerError::ProjectNotFound(_)) => Ok(None),
Err(e) => Err(e),
}
}
}
// CurseForge API models

View file

@ -403,6 +403,17 @@ impl PlatformClient for GitHubPlatform {
Ok(None)
}
}
async fn request_project_from_slug(
&self,
slug: &str,
) -> Result<Option<Project>> {
match self.request_project(slug, &[], &[]).await {
Ok(project) => Ok(Some(project)),
Err(PakkerError::ProjectNotFound(_)) => Ok(None),
Err(e) => Err(e),
}
}
}
// GitHub API models

View file

@ -254,6 +254,28 @@ impl PlatformClient for ModrinthPlatform {
let url = format!("{MODRINTH_API_BASE}/version_file/{hash}");
self.lookup_by_hash_url(&url).await
}
async fn request_project_from_slug(
&self,
slug: &str,
) -> Result<Option<Project>> {
let url = format!("{MODRINTH_API_BASE}/project/{slug}");
let response = self.client.get(&url).send().await?;
if response.status().as_u16() == 404 {
return Ok(None);
}
if !response.status().is_success() {
return Err(PakkerError::PlatformApiError(format!(
"Modrinth API error: {}",
response.status()
)));
}
let mr_project: ModrinthProject = response.json().await?;
Ok(Some(self.convert_project(mr_project)))
}
}
// Modrinth API models

View file

@ -0,0 +1,205 @@
use std::sync::Arc;
use async_trait::async_trait;
use super::traits::PlatformClient;
use crate::{
error::{PakkerError, Result},
model::{Project, ProjectFile},
};
/// Multiplatform platform client that aggregates CurseForge and Modrinth.
/// It attempts to resolve projects on both platforms and cross-references
/// them via slugs when a project exists on only one platform.
pub struct MultiplatformPlatform {
curseforge: Arc<dyn PlatformClient>,
modrinth: Arc<dyn PlatformClient>,
}
impl MultiplatformPlatform {
pub fn new(
curseforge: Arc<dyn PlatformClient>,
modrinth: Arc<dyn PlatformClient>,
) -> Self {
Self {
curseforge,
modrinth,
}
}
/// Try to fetch a project, returning Ok(None) for "not found" errors.
async fn try_request_project(
&self,
client: &Arc<dyn PlatformClient>,
identifier: &str,
) -> Result<Option<Project>> {
match client.request_project(identifier, &[], &[]).await {
Ok(project) => Ok(Some(project)),
Err(e) => {
let is_not_found = matches!(
e,
PakkerError::ProjectNotFound(_) | PakkerError::InvalidResponse(_)
);
if is_not_found { Ok(None) } else { Err(e) }
},
}
}
}
#[async_trait]
impl PlatformClient for MultiplatformPlatform {
async fn request_project(
&self,
identifier: &str,
_mc_versions: &[String],
_loaders: &[String],
) -> Result<Project> {
// Try both platforms in parallel
let cf_future = self.try_request_project(&self.curseforge, identifier);
let mr_future = self.try_request_project(&self.modrinth, identifier);
let (cf_result, mr_result) = tokio::join!(cf_future, mr_future);
// Handle results - extract Options, propagate first error if both fail
let (cf_project, mr_project) = match (cf_result, mr_result) {
(Ok(Some(cf)), Ok(Some(mr))) => (Some(cf), Some(mr)),
(Ok(None), Ok(None)) => (None, None),
(Ok(None), Ok(Some(mr))) => (None, Some(mr)),
(Ok(Some(cf)), Ok(None)) => (Some(cf), None),
(Err(_e), Ok(None)) | (Ok(None), Err(_e)) => (None, None),
(Err(e), Ok(Some(_))) | (Ok(Some(_)), Err(e)) => {
return Err(e);
},
(Err(e), Err(_)) => return Err(e),
};
// Cross-reference: if project exists on only one platform, fetch from the
// other using the slug
let mut cf_project = cf_project;
let mut mr_project = mr_project;
let mr_found_and_cf_missing = mr_project.is_some() && cf_project.is_none();
if mr_found_and_cf_missing
&& let Some(ref mr) = mr_project
&& let Some(cf_slug) = mr.slug.get("curseforge")
&& let Ok(Some(cf)) =
self.curseforge.request_project_from_slug(cf_slug).await
{
cf_project = Some(cf);
}
let cf_found_and_mr_missing = cf_project.is_some() && mr_project.is_none();
if cf_found_and_mr_missing
&& let Some(ref cf) = cf_project
&& let Some(mr_slug) = cf.slug.get("modrinth")
&& let Ok(Some(mr)) =
self.modrinth.request_project_from_slug(mr_slug).await
{
mr_project = Some(mr);
}
// Merge projects or return whichever was found
let combined = match (cf_project, mr_project) {
(Some(cf), Some(mr)) => cf.merged(mr)?,
(Some(cf), None) => cf,
(None, Some(mr)) => mr,
(None, None) => {
return Err(PakkerError::ProjectNotFound(identifier.to_string()));
},
};
Ok(combined)
}
async fn request_project_files(
&self,
project_id: &str,
mc_versions: &[String],
loaders: &[String],
) -> Result<Vec<ProjectFile>> {
// Multiplatform doesn't directly support files - use
// request_project_with_files
let project = self
.request_project_with_files(project_id, mc_versions, loaders)
.await?;
Ok(project.files)
}
async fn request_project_with_files(
&self,
identifier: &str,
mc_versions: &[String],
loaders: &[String],
) -> Result<Project> {
// First get the combined project from both platforms
let mut project = self
.request_project(identifier, mc_versions, loaders)
.await?;
// Now fetch files from both platforms in parallel
let cf_project_id = project.id.get("curseforge").cloned();
let mr_project_id = project.id.get("modrinth").cloned();
let cf_files_future = async {
if let Some(ref id) = cf_project_id {
self
.curseforge
.request_project_files(id, mc_versions, loaders)
.await
} else {
Ok(Vec::new())
}
};
let mr_files_future = async {
if let Some(ref id) = mr_project_id {
self
.modrinth
.request_project_files(id, mc_versions, loaders)
.await
} else {
Ok(Vec::new())
}
};
let (cf_files, mr_files) = tokio::join!(cf_files_future, mr_files_future);
let mut all_files = cf_files?;
all_files.extend(mr_files?);
project.files = all_files;
Ok(project)
}
async fn lookup_by_hash(&self, hash: &str) -> Result<Option<Project>> {
// Try both platforms in parallel
let cf_future = self.curseforge.lookup_by_hash(hash);
let mr_future = self.modrinth.lookup_by_hash(hash);
let (cf_result, mr_result) = tokio::join!(cf_future, mr_future);
match (cf_result?, mr_result?) {
(Some(cf), Some(mr)) => cf.merged(mr).map(Some),
(Some(project), None) | (None, Some(project)) => Ok(Some(project)),
(None, None) => Ok(None),
}
}
async fn request_project_from_slug(
&self,
slug: &str,
) -> Result<Option<Project>> {
let cf_future = self.curseforge.request_project_from_slug(slug);
let mr_future = self.modrinth.request_project_from_slug(slug);
let (cf_result, mr_result) = tokio::join!(cf_future, mr_future);
match (cf_result, mr_result) {
(Ok(Some(cf)), Ok(Some(mr))) => cf.merged(mr).map(Some),
(Ok(Some(project)), Ok(None)) | (Ok(None), Ok(Some(project))) => {
Ok(Some(project))
},
(Ok(None), Ok(None)) => Ok(None),
(Err(e), _) | (_, Err(e)) => Err(e),
}
}
}

View file

@ -29,4 +29,12 @@ pub trait PlatformClient: Send + Sync {
) -> Result<Project>;
async fn lookup_by_hash(&self, hash: &str) -> Result<Option<Project>>;
/// Request a project using its platform-specific slug.
/// This is used by Multiplatform to cross-reference projects between
/// platforms.
async fn request_project_from_slug(
&self,
slug: &str,
) -> Result<Option<Project>>;
}