platform/multiplatform: add multiplatform client with cross-ref
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ie2cf48136e5a9017265a3b0ef26619356a6a6964
This commit is contained in:
parent
a8bf8f9f3f
commit
5772200da9
6 changed files with 276 additions and 0 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
205
src/platform/multiplatform.rs
Normal file
205
src/platform/multiplatform.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue