Skip to main content

nitro_net/
modrinth.rs

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/// A Modrinth project (mod, resource pack, etc.)
11#[derive(Deserialize, Serialize, Clone, Default)]
12#[serde(default)]
13pub struct Project {
14	/// The ID of the project
15	#[serde(alias = "project_id")]
16	pub id: String,
17	/// The slug of the project
18	pub slug: String,
19	/// The type of this project and its files
20	pub project_type: ProjectType,
21	/// The ID's of the available project versions
22	pub versions: Vec<String>,
23	/// The Minecraft versions this project is available for
24	pub game_versions: Vec<String>,
25	/// The loaders this project is available for
26	pub loaders: Vec<ModrinthLoader>,
27	/// The project's support on the client side
28	pub client_side: SideSupport,
29	/// The project's support on the server side
30	pub server_side: SideSupport,
31	/// The project's team ID
32	pub team: String,
33	/// The display name of the project
34	pub title: String,
35	/// The short description of the project
36	pub description: String,
37	/// The long description of the project
38	pub body: Option<String>,
39	/// URL to the icon
40	pub icon_url: Option<String>,
41	/// URL to the issue tracker
42	pub issues_url: Option<String>,
43	/// URL to the source
44	pub source_url: Option<String>,
45	/// URL to the wiki
46	pub wiki_url: Option<String>,
47	/// URL to the Discord
48	pub discord_url: Option<String>,
49	/// Donation URLs
50	pub donation_urls: Vec<DonationLink>,
51	/// The license of the project
52	pub license: License,
53	/// The gallery items of the project
54	pub gallery: Option<Vec<GalleryEntry>>,
55	/// Categories for the project
56	pub categories: Vec<String>,
57	/// Number of downloads for the project
58	pub downloads: u32,
59}
60
61/// The type of a Modrinth project
62#[derive(Deserialize, Serialize, Copy, Clone, Default, PartialEq, Eq)]
63#[serde(rename_all = "lowercase")]
64pub enum ProjectType {
65	/// A mod project
66	#[default]
67	Mod,
68	/// A modpack project
69	Modpack,
70	/// A resource pack project
71	ResourcePack,
72	/// A shader project
73	Shader,
74	/// A datapack project
75	Datapack,
76	/// A plugin project
77	Plugin,
78}
79
80/// Get a project from the API
81pub 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
89/// Get a project from the API, returning none if it does not exist
90pub 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
116/// Get the raw response of a project from the API
117pub 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
125/// Format the URL for the get_project API
126fn format_get_project_url(project_id: &str) -> String {
127	format!("https://api.modrinth.com/v2/project/{project_id}")
128}
129
130/// Get multiple Modrinth projects
131pub 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	// Use the multiple-projects API endpoint as it's faster
139	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/// Release channel for a Modrinth project version
146#[derive(Deserialize, Serialize, Clone, Copy)]
147#[serde(rename_all = "snake_case")]
148pub enum ReleaseChannel {
149	/// A finished release version
150	Release,
151	/// An unfinished beta version
152	Beta,
153	/// An unfinished alpha version
154	Alpha,
155}
156
157/// A Modrinth project version
158#[derive(Deserialize, Serialize, Clone)]
159pub struct Version {
160	/// The ID of this version
161	pub id: String,
162	/// The ID of the project this version is from
163	pub project_id: String,
164	/// The name of this version
165	pub name: String,
166	/// The version number of this version
167	pub version_number: String,
168	/// The type / release channel of this version
169	pub version_type: ReleaseChannel,
170	/// The loaders that this version supports
171	pub loaders: Vec<ModrinthLoader>,
172	/// The list of downloads for this version
173	pub files: Vec<Download>,
174	/// The game versions this version supports
175	pub game_versions: Vec<String>,
176	/// The dependencies that this version has
177	pub dependencies: Vec<Dependency>,
178	/// Whether this version is featured
179	pub featured: bool,
180	/// The date this version was published in ISO-8601
181	pub date_published: String,
182}
183
184/// Loader for a Modrinth project version
185#[derive(Deserialize, Serialize, Clone, PartialEq, Eq)]
186#[serde(untagged)]
187pub enum ModrinthLoader {
188	/// A loader that is known
189	Known(KnownLoader),
190	/// A loader that we do not know about
191	Unknown(String),
192}
193
194/// A known plugin / mod loader that Modrinth supports
195#[derive(Deserialize, Serialize, Copy, Clone, PartialEq, Eq)]
196#[serde(rename_all = "snake_case")]
197pub enum KnownLoader {
198	/// The Vanilla game
199	Minecraft,
200	/// MinecraftForge
201	Forge,
202	/// Fabric loader
203	Fabric,
204	/// Quilt loader
205	Quilt,
206	/// NeoForged loader
207	#[serde(rename = "neoforge")]
208	NeoForged,
209	/// Rift loader
210	Rift,
211	/// Liteloader
212	Liteloader,
213	/// Risugami's Modloader
214	#[serde(rename = "modloader")]
215	Risugamis,
216	/// Bukkit loaders
217	Bukkit,
218	/// Spigot server
219	Spigot,
220	/// Paper server
221	Paper,
222	/// Sponge server
223	Sponge,
224	/// Purpur server
225	Purpur,
226	/// Folia server
227	Folia,
228	/// Iris shader loader
229	Iris,
230	/// Optifine shader loader
231	Optifine,
232	/// Datapack loader
233	Datapack,
234	/// Velocity loader
235	Velocity,
236	/// BungeeCord loader
237	#[serde(rename = "bungeecord")]
238	BungeeCord,
239	/// Waterfall loader
240	Waterfall,
241}
242
243impl Version {
244	/// Returns the primary file download for this version
245	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
257/// Gets all the versions for a Modrinth project
258pub 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
266/// Get a Modrinth project version
267pub 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
275/// Get the raw response of a version from the API
276pub 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
284/// Format the URL for the get_version API
285fn format_get_version_url(version_id: &str) -> String {
286	format!("https://api.modrinth.com/v2/version/{version_id}")
287}
288
289/// Get multiple Modrinth project versions
290pub 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	// Use the multiple-versions API endpoint as it's faster
299	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/// A file download from the Modrinth API
306#[derive(Deserialize, Serialize, Clone)]
307pub struct Download {
308	/// The URL to the file download
309	pub url: String,
310	/// The name of the file
311	pub filename: String,
312	/// Whether or not this is the primary file for this version
313	pub primary: bool,
314	/// Hashes for this file
315	pub hashes: Hashes,
316}
317
318/// Hashes for a Modrinth file
319#[derive(Deserialize, Serialize, Clone)]
320pub struct Hashes {
321	/// SHA-512 hash
322	pub sha512: String,
323}
324
325/// A version dependency
326#[derive(Deserialize, Serialize, Clone)]
327pub struct Dependency {
328	/// The ID of the project
329	pub project_id: Option<String>,
330	/// The ID of the version
331	pub version_id: Option<String>,
332	/// The type of the dependency
333	pub dependency_type: DependencyType,
334}
335
336/// The type of a dependency
337#[derive(Deserialize, Serialize, Clone, Copy)]
338#[serde(rename_all = "snake_case")]
339pub enum DependencyType {
340	/// A required dependency
341	Required,
342	/// An optional / recommended dependency
343	Optional,
344	/// An incompatible dependency
345	Incompatible,
346	/// An embedded dependency
347	Embedded,
348}
349
350/// Information about a project license
351#[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/// Long information about a project license
365#[derive(Deserialize, Serialize, Clone)]
366pub struct LongLicense {
367	/// The short ID of the license
368	pub id: String,
369	/// The URL to a custom license
370	pub url: Option<String>,
371}
372
373/// Information about a donation link
374#[derive(Deserialize, Serialize, Clone)]
375pub struct DonationLink {
376	/// The URL of the link
377	pub url: String,
378}
379
380/// An entry in a project's gallery
381#[derive(Deserialize, Serialize, Clone)]
382#[serde(untagged)]
383pub enum GalleryEntry {
384	Simple(String),
385	Full(FullGalleryEntry),
386}
387
388impl GalleryEntry {
389	/// Get the URL to this entry
390	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/// An entry in a project's gallery
399#[derive(Deserialize, Serialize, Clone)]
400pub struct FullGalleryEntry {
401	/// The URL to the low-quality version of the gallery image
402	pub url: String,
403	/// The URL to the high-quality version of the gallery image
404	pub raw_url: String,
405	/// Whether the gallery image is a featured banner on the project page
406	pub featured: bool,
407}
408
409/// Support status for a project on a specific side
410#[derive(Deserialize, Serialize, Clone, Copy, Default)]
411#[serde(rename_all = "snake_case")]
412pub enum SideSupport {
413	/// Required to be on this side
414	Required,
415	/// Can optionally be on this side
416	Optional,
417	/// Unsupported on this side
418	Unsupported,
419	/// Support unknown
420	#[default]
421	Unknown,
422}
423
424/// Get the team members of a project
425pub 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
430/// Get multiple Modrinth teams
431pub 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	// Use the multiple-teams API endpoint as it's faster
439	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/// A member of a project team
446#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, PartialOrd, Ord)]
447pub struct Member {
448	/// The ordering of the team member
449	pub ordering: i32,
450	/// The user that represents this member
451	pub user: User,
452	/// The ID of the team this member is a part of
453	pub team_id: String,
454}
455
456/// A user on the platform
457#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, PartialOrd, Ord)]
458pub struct User {
459	/// The user's username
460	pub username: String,
461}
462
463/// Search projects from the Modrinth API. Note that the projects returned by this have many default fields and should NOT be used as the final projects.
464pub 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	/// The results
578	pub hits: Vec<SearchedProject>,
579	/// The total number of results outside limits
580	pub total_hits: usize,
581}
582
583/// A project result in the search
584#[derive(Deserialize, Serialize, Clone, Default)]
585#[serde(default)]
586pub struct SearchedProject {
587	/// The ID of the project
588	#[serde(alias = "project_id")]
589	pub id: String,
590	/// The slug of the project
591	pub slug: String,
592	/// The type of this project and its files
593	pub project_type: ProjectType,
594	/// The display name of the project
595	pub title: String,
596	/// A short description of the project
597	pub description: String,
598	/// Displayed categories of the project on it's grid tile
599	pub display_categories: Vec<String>,
600	/// Minecraft versions this project supports
601	pub versions: Vec<String>,
602	/// Icon for this project
603	pub icon_url: Option<String>,
604	/// Gallery for this project
605	pub gallery: Option<Vec<String>>,
606	/// Featured gallery image for this project
607	pub featured_gallery: Option<String>,
608	/// Number of downloads for this project
609	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}