1use crate::download::{self, user_agent};
2use anyhow::{Context, anyhow};
3use nitro_shared::{
4 loaders::Loader,
5 pkg::{PackageCategory, PackageKind, PackageSearchParameters},
6};
7use reqwest::{Client, StatusCode};
8use serde::{Deserialize, Serialize};
9
10#[derive(Deserialize, Serialize, Clone, Default)]
12#[serde(default)]
13pub struct Project {
14 #[serde(alias = "project_id")]
16 pub id: String,
17 pub slug: String,
19 pub project_type: ProjectType,
21 pub versions: Vec<String>,
23 pub game_versions: Vec<String>,
25 pub loaders: Vec<ModrinthLoader>,
27 pub client_side: SideSupport,
29 pub server_side: SideSupport,
31 pub team: String,
33 pub title: String,
35 pub description: String,
37 pub body: Option<String>,
39 pub icon_url: Option<String>,
41 pub issues_url: Option<String>,
43 pub source_url: Option<String>,
45 pub wiki_url: Option<String>,
47 pub discord_url: Option<String>,
49 pub donation_urls: Vec<DonationLink>,
51 pub license: License,
53 pub gallery: Option<Vec<GalleryEntry>>,
55 pub categories: Vec<String>,
57 pub downloads: u32,
59}
60
61#[derive(Deserialize, Serialize, Copy, Clone, Default, PartialEq, Eq)]
63#[serde(rename_all = "lowercase")]
64pub enum ProjectType {
65 #[default]
67 Mod,
68 Modpack,
70 ResourcePack,
72 Shader,
74 Datapack,
76 Plugin,
78}
79
80pub async fn get_project(project_id: &str, client: &Client) -> anyhow::Result<Project> {
82 let url = format_get_project_url(project_id);
83 let out = download::json(url, client)
84 .await
85 .context("Failed to download Modrinth project")?;
86 Ok(out)
87}
88
89pub async fn get_project_optional(
91 project_id: &str,
92 client: &Client,
93) -> anyhow::Result<Option<Project>> {
94 let url = format_get_project_url(project_id);
95
96 let resp = client
97 .get(url)
98 .header("User-Agent", user_agent())
99 .send()
100 .await
101 .context("Failed to send request")?;
102 if resp.status() == StatusCode::NOT_FOUND {
103 return Ok(None);
104 }
105
106 let resp = resp
107 .error_for_status()
108 .context("Server returned an error")?;
109
110 resp.json()
111 .await
112 .map(Some)
113 .context("Failed to deserialize JSON")
114}
115
116pub async fn get_project_raw(project_id: &str, client: &Client) -> anyhow::Result<String> {
118 let url = format_get_project_url(project_id);
119 let out = download::text(url, client)
120 .await
121 .context("Failed to download Modrinth project")?;
122 Ok(out)
123}
124
125fn format_get_project_url(project_id: &str) -> String {
127 format!("https://api.modrinth.com/v2/project/{project_id}")
128}
129
130pub async fn get_multiple_projects(
132 projects: &[String],
133 client: &Client,
134) -> anyhow::Result<Vec<Project>> {
135 if projects.is_empty() {
136 return Ok(Vec::new());
137 }
138 let param = serde_json::to_string(projects)
140 .context("Failed to convert project list to API parameter")?;
141 let url = format!("https://api.modrinth.com/v2/projects?ids={param}");
142 download::json(url, client).await
143}
144
145#[derive(Deserialize, Serialize, Clone, Copy)]
147#[serde(rename_all = "snake_case")]
148pub enum ReleaseChannel {
149 Release,
151 Beta,
153 Alpha,
155}
156
157#[derive(Deserialize, Serialize, Clone)]
159pub struct Version {
160 pub id: String,
162 pub project_id: String,
164 pub name: String,
166 pub version_number: String,
168 pub version_type: ReleaseChannel,
170 pub loaders: Vec<ModrinthLoader>,
172 pub files: Vec<Download>,
174 pub game_versions: Vec<String>,
176 pub dependencies: Vec<Dependency>,
178 pub featured: bool,
180 pub date_published: String,
182}
183
184#[derive(Deserialize, Serialize, Clone, PartialEq, Eq)]
186#[serde(untagged)]
187pub enum ModrinthLoader {
188 Known(KnownLoader),
190 Unknown(String),
192}
193
194#[derive(Deserialize, Serialize, Copy, Clone, PartialEq, Eq)]
196#[serde(rename_all = "snake_case")]
197pub enum KnownLoader {
198 Minecraft,
200 Forge,
202 Fabric,
204 Quilt,
206 #[serde(rename = "neoforge")]
208 NeoForged,
209 Rift,
211 Liteloader,
213 #[serde(rename = "modloader")]
215 Risugamis,
216 Bukkit,
218 Spigot,
220 Paper,
222 Sponge,
224 Purpur,
226 Folia,
228 Iris,
230 Optifine,
232 Datapack,
234 Velocity,
236 #[serde(rename = "bungeecord")]
238 BungeeCord,
239 Waterfall,
241}
242
243impl Version {
244 pub fn get_primary_download(&self) -> anyhow::Result<&Download> {
246 let primary = self.files.iter().find(|x| x.primary);
247 if let Some(primary) = primary {
248 Ok(primary)
249 } else {
250 self.files
251 .first()
252 .ok_or(anyhow!("Version has no downloads"))
253 }
254 }
255}
256
257pub async fn get_project_versions(
259 project_id: &str,
260 client: &Client,
261) -> anyhow::Result<Vec<Version>> {
262 let url = format!("https://api.modrinth.com/v2/project/{project_id}/version");
263 download::json(url, client).await
264}
265
266pub async fn get_version(version_id: &str, client: &Client) -> anyhow::Result<Version> {
268 let url = format_get_version_url(version_id);
269 let out = download::json(url, client)
270 .await
271 .context("Failed to download Modrinth version")?;
272 Ok(out)
273}
274
275pub async fn get_version_raw(version_id: &str, client: &Client) -> anyhow::Result<String> {
277 let url = format_get_version_url(version_id);
278 let out = download::text(url, client)
279 .await
280 .context("Failed to download Modrinth version")?;
281 Ok(out)
282}
283
284fn format_get_version_url(version_id: &str) -> String {
286 format!("https://api.modrinth.com/v2/version/{version_id}")
287}
288
289pub async fn get_multiple_versions(
291 versions: &[String],
292 client: &Client,
293) -> anyhow::Result<Vec<Version>> {
294 if versions.is_empty() {
295 return Ok(Vec::new());
296 }
297
298 let param = serde_json::to_string(versions)
300 .context("Failed to convert version list to API parameter")?;
301 let url = format!("https://api.modrinth.com/v2/versions?ids={param}");
302 download::json(url, client).await
303}
304
305#[derive(Deserialize, Serialize, Clone)]
307pub struct Download {
308 pub url: String,
310 pub filename: String,
312 pub primary: bool,
314 pub hashes: Hashes,
316}
317
318#[derive(Deserialize, Serialize, Clone)]
320pub struct Hashes {
321 pub sha512: String,
323}
324
325#[derive(Deserialize, Serialize, Clone)]
327pub struct Dependency {
328 pub project_id: Option<String>,
330 pub version_id: Option<String>,
332 pub dependency_type: DependencyType,
334}
335
336#[derive(Deserialize, Serialize, Clone, Copy)]
338#[serde(rename_all = "snake_case")]
339pub enum DependencyType {
340 Required,
342 Optional,
344 Incompatible,
346 Embedded,
348}
349
350#[derive(Deserialize, Serialize, Clone)]
352#[serde(untagged)]
353pub enum License {
354 Short(String),
355 Long(LongLicense),
356}
357
358impl Default for License {
359 fn default() -> Self {
360 Self::Short("ARR".into())
361 }
362}
363
364#[derive(Deserialize, Serialize, Clone)]
366pub struct LongLicense {
367 pub id: String,
369 pub url: Option<String>,
371}
372
373#[derive(Deserialize, Serialize, Clone)]
375pub struct DonationLink {
376 pub url: String,
378}
379
380#[derive(Deserialize, Serialize, Clone)]
382#[serde(untagged)]
383pub enum GalleryEntry {
384 Simple(String),
385 Full(FullGalleryEntry),
386}
387
388impl GalleryEntry {
389 pub fn get_url(&self) -> &str {
391 match self {
392 Self::Simple(url) => url,
393 Self::Full(entry) => &entry.raw_url,
394 }
395 }
396}
397
398#[derive(Deserialize, Serialize, Clone)]
400pub struct FullGalleryEntry {
401 pub url: String,
403 pub raw_url: String,
405 pub featured: bool,
407}
408
409#[derive(Deserialize, Serialize, Clone, Copy, Default)]
411#[serde(rename_all = "snake_case")]
412pub enum SideSupport {
413 Required,
415 Optional,
417 Unsupported,
419 #[default]
421 Unknown,
422}
423
424pub async fn get_project_team(project_id: &str, client: &Client) -> anyhow::Result<Vec<Member>> {
426 let url = format!("https://api.modrinth.com/v2/project/{project_id}/members");
427 download::json(url, client).await
428}
429
430pub async fn get_multiple_teams(
432 teams: &[String],
433 client: &Client,
434) -> anyhow::Result<Vec<Vec<Member>>> {
435 if teams.is_empty() {
436 return Ok(Vec::new());
437 }
438 let param =
440 serde_json::to_string(teams).context("Failed to convert team list to API parameter")?;
441 let url = format!("https://api.modrinth.com/v2/teams?ids={param}");
442 download::json(url, client).await
443}
444
445#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, PartialOrd, Ord)]
447pub struct Member {
448 pub ordering: i32,
450 pub user: User,
452 pub team_id: String,
454}
455
456#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, PartialOrd, Ord)]
458pub struct User {
459 pub username: String,
461}
462
463pub async fn search_projects(
465 params: PackageSearchParameters,
466 client: &Client,
467) -> anyhow::Result<SearchResults> {
468 let limit = if params.count > 100 {
469 100
470 } else {
471 params.count
472 };
473 let search = if let Some(search) = params.search {
474 format!("&query={search}")
475 } else {
476 String::new()
477 };
478
479 let mut facets = Vec::new();
480
481 let types = params
482 .types
483 .into_iter()
484 .map(|x| match x {
485 PackageKind::Mod => "mod",
486 PackageKind::ResourcePack => "resourcepack",
487 PackageKind::Datapack => "datapack",
488 PackageKind::Plugin => "plugin",
489 PackageKind::Shader => "shader",
490 PackageKind::Bundle | PackageKind::Modpack => "modpack",
491 })
492 .map(|x| format!("\"project_types={x}\""))
493 .collect::<Vec<_>>()
494 .join(",");
495 if !types.is_empty() {
496 facets.push(format!("[{types}]"));
497 }
498
499 if !params.minecraft_versions.is_empty() {
500 let versions = params
501 .minecraft_versions
502 .into_iter()
503 .map(|x| format!("\"versions={x}\""))
504 .collect::<Vec<_>>()
505 .join(",");
506 facets.push(format!("[{versions}]"));
507 };
508
509 if !params.loaders.is_empty() {
510 let loaders = params
511 .loaders
512 .into_iter()
513 .filter_map(|x| match x {
514 Loader::Fabric => Some(KnownLoader::Fabric),
515 Loader::Forge => Some(KnownLoader::Forge),
516 Loader::Folia => Some(KnownLoader::Folia),
517 Loader::Quilt => Some(KnownLoader::Quilt),
518 Loader::Rift => Some(KnownLoader::Rift),
519 Loader::Risugamis => Some(KnownLoader::Risugamis),
520 Loader::LiteLoader => Some(KnownLoader::Liteloader),
521 Loader::Paper => Some(KnownLoader::Paper),
522 Loader::Purpur => Some(KnownLoader::Purpur),
523 Loader::CraftBukkit => Some(KnownLoader::Bukkit),
524 Loader::Spigot => Some(KnownLoader::Spigot),
525 Loader::Sponge => Some(KnownLoader::Sponge),
526 Loader::NeoForged => Some(KnownLoader::NeoForged),
527 _ => None,
528 })
529 .map(|x| {
530 format!(
531 "\"categories={}\"",
532 serde_json::to_string(&x).unwrap().replace("\"", "")
533 )
534 })
535 .collect::<Vec<_>>();
536 if !loaders.is_empty() {
537 facets.push(format!("[{}]", loaders.join(",")));
538 }
539 };
540
541 if !params.categories.is_empty() {
542 let categories = params
543 .categories
544 .into_iter()
545 .flat_map(convert_category)
546 .map(|x| {
547 format!(
548 "\"categories={}\"",
549 serde_json::to_string(&x).unwrap().replace("\"", "")
550 )
551 })
552 .collect::<Vec<_>>();
553 if categories.is_empty() {
554 return Ok(SearchResults::default());
555 }
556
557 let categories = categories.join(",");
558 facets.push(format!("[{categories}]"));
559 };
560
561 let facets_inside = facets.join(",");
562 let facets = if facets_inside.is_empty() {
563 String::new()
564 } else {
565 format!("&facets=[{facets_inside}]")
566 };
567 let url = format!(
568 "https://api.modrinth.com/v2/search?limit={limit}{search}{facets}&offset={}",
569 params.skip
570 );
571
572 download::json(url, client).await
573}
574
575#[derive(Deserialize, Serialize, Clone, Default)]
576pub struct SearchResults {
577 pub hits: Vec<SearchedProject>,
579 pub total_hits: usize,
581}
582
583#[derive(Deserialize, Serialize, Clone, Default)]
585#[serde(default)]
586pub struct SearchedProject {
587 #[serde(alias = "project_id")]
589 pub id: String,
590 pub slug: String,
592 pub project_type: ProjectType,
594 pub title: String,
596 pub description: String,
598 pub display_categories: Vec<String>,
600 pub versions: Vec<String>,
602 pub icon_url: Option<String>,
604 pub gallery: Option<Vec<String>>,
606 pub featured_gallery: Option<String>,
608 pub downloads: u32,
610}
611
612fn convert_category(category: PackageCategory) -> &'static [&'static str] {
613 match category {
614 PackageCategory::Blocks => &["blocks"],
615 PackageCategory::Building => &["blocks", "decoration"],
616 PackageCategory::Decoration => &["decoration"],
617 PackageCategory::Exploration | PackageCategory::Worldgen => &["worldgen", "adventure"],
618 PackageCategory::Adventure => &["adventure"],
619 PackageCategory::Atmosphere => &["atmosphere"],
620 PackageCategory::Audio => &["audio"],
621 PackageCategory::Cartoon => &["cartoon"],
622 PackageCategory::Challenge => &["challenging"],
623 PackageCategory::Combat => &["combat"],
624 PackageCategory::Economy => &["economy"],
625 PackageCategory::Entities => &["entities"],
626 PackageCategory::Equipment => &["equipment"],
627 PackageCategory::Fantasy => &["fantasy"],
628 PackageCategory::Fonts => &["fonts"],
629 PackageCategory::Food => &["food"],
630 PackageCategory::GameMechanics => &["game-mechanics"],
631 PackageCategory::Gui => &["gui"],
632 PackageCategory::Items => &["items"],
633 PackageCategory::Extensive => &["kitchen-sink"],
634 PackageCategory::Library => &["library"],
635 PackageCategory::Lightweight => &["lightweight"],
636 PackageCategory::Language => &["locale"],
637 PackageCategory::Magic => &["magic"],
638 PackageCategory::Minigame => &["minigame"],
639 PackageCategory::Mobs => &["mobs"],
640 PackageCategory::Multiplayer => &["multiplayer"],
641 PackageCategory::Optimization => &["optimization"],
642 PackageCategory::Realistic => &["realistic"],
643 PackageCategory::Simplistic => &["simplistic"],
644 PackageCategory::Social => &["social"],
645 PackageCategory::Storage => &["storage"],
646 PackageCategory::Technology => &["technology"],
647 PackageCategory::Transportation => &["transportation"],
648 PackageCategory::Tweaks => &["tweaks"],
649 PackageCategory::Utility => &["utility"],
650 PackageCategory::VanillaPlus => &["vanilla-like"],
651 _ => &[],
652 }
653}