1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use serde::{de::DeserializeOwned, Deserialize, Serialize};
7
8use crate::{
9 download::{download_batch, DownloadTask},
10 error::{HexoError, Result},
11 install::{loader::ProgressFn, vanilla::LoaderType},
12 mods::curseforge::{CfMod, CfModFile, CurseForgeClient},
13};
14
15use super::{
16 install_pack_loader, instance_game_dir, safe_join, ManualDownload, ModpackInfo,
17 ModpackInstallResult,
18};
19
20const API_BASE: &str = "https://api.feed-the-beast.com/v1/modpacks/public";
21
22#[derive(Debug, Deserialize, Serialize, Clone)]
24pub struct FtbPack {
25 pub id: u64,
26 pub name: String,
27 #[serde(default)]
28 pub private: bool,
29 #[serde(default)]
30 pub versions: Vec<FtbVersion>,
31}
32
33#[derive(Debug, Deserialize, Serialize, Clone)]
35pub struct FtbVersion {
36 pub id: u64,
37 pub name: String,
38 #[serde(default)]
39 pub r#type: String,
40 #[serde(default)]
41 pub updated: i64,
42 #[serde(default)]
43 pub private: bool,
44 #[serde(default)]
45 pub targets: Vec<FtbTarget>,
46}
47
48#[derive(Debug, Deserialize, Serialize, Clone)]
50pub struct FtbTarget {
51 pub name: String,
52 pub version: String,
53 #[serde(default)]
54 pub r#type: String,
55}
56
57#[derive(Debug, Deserialize, Serialize, Clone)]
59pub struct FtbVersionManifest {
60 pub id: u64,
61 pub name: String,
62 #[serde(default)]
63 pub targets: Vec<FtbTarget>,
64 #[serde(default)]
65 pub files: Vec<FtbFile>,
66}
67
68#[derive(Debug, Deserialize, Serialize, Clone)]
70pub struct FtbFile {
71 pub path: String,
72 pub name: String,
73 #[serde(default)]
74 pub url: String,
75 #[serde(default)]
76 pub sha1: String,
77 #[serde(default)]
78 pub size: u64,
79 #[serde(default)]
80 pub clientonly: bool,
81 #[serde(default)]
82 pub serveronly: bool,
83 #[serde(default)]
84 pub optional: bool,
85 #[serde(default)]
86 pub curseforge: Option<FtbCurseForgeRef>,
87}
88
89#[derive(Debug, Deserialize, Serialize, Clone)]
91pub struct FtbCurseForgeRef {
92 #[serde(deserialize_with = "deserialize_id")]
93 pub project: u64,
94 #[serde(deserialize_with = "deserialize_id")]
95 pub file: u64,
96}
97
98fn deserialize_id<'de, D: serde::Deserializer<'de>>(
100 deserializer: D,
101) -> std::result::Result<u64, D::Error> {
102 #[derive(Deserialize)]
103 #[serde(untagged)]
104 enum Id {
105 Number(u64),
106 Text(String),
107 }
108 match Id::deserialize(deserializer)? {
109 Id::Number(id) => Ok(id),
110 Id::Text(id) => id.parse().map_err(serde::de::Error::custom),
111 }
112}
113
114impl FtbVersionManifest {
115 pub fn info(&self, pack_name: &str) -> Result<ModpackInfo> {
117 let mc = self
118 .targets
119 .iter()
120 .find(|t| t.name == "minecraft")
121 .filter(|t| !t.version.is_empty())
122 .ok_or_else(|| {
123 HexoError::Other("FTB manifest has no Minecraft target version".into())
124 })?;
125 if self.targets.iter().any(|t| t.name == "quilt") {
126 return Err(HexoError::UnsupportedLoader("quilt".into()));
127 }
128 let mut loader = LoaderType::Vanilla;
129 let mut loader_version = None;
130 for (name, kind) in [
131 ("neoforge", LoaderType::NeoForge),
132 ("forge", LoaderType::Forge),
133 ("fabric", LoaderType::Fabric),
134 ] {
135 if let Some(target) = self.targets.iter().find(|t| t.name == name) {
136 let version = if kind == LoaderType::Forge {
137 target
138 .version
139 .strip_prefix(&format!("{}-", mc.version))
140 .unwrap_or(&target.version)
141 } else {
142 &target.version
143 };
144 if version.is_empty() {
145 return Err(HexoError::Other(format!(
146 "FTB {name} target has no version"
147 )));
148 }
149 loader = kind;
150 loader_version = Some(version.to_string());
151 break;
152 }
153 }
154 ModpackInfo {
155 name: pack_name.to_string(),
156 version: Some(self.name.clone()),
157 mc_version: mc.version.clone(),
158 loader,
159 loader_version,
160 }.validated()
161 }
162
163 fn selected_files(&self, include_optional: bool) -> impl Iterator<Item = &FtbFile> {
164 self.files
165 .iter()
166 .filter(move |f| !f.serveronly && (!f.optional || include_optional))
167 }
168}
169
170fn parse_response<T: DeserializeOwned>(value: serde_json::Value, id: &str) -> Result<T> {
171 if value.get("status").and_then(|s| s.as_str()) != Some("success") {
172 return Err(HexoError::VersionNotFound(format!("FTB {id}")));
173 }
174 Ok(serde_json::from_value(value)?)
175}
176
177async fn fetch<T: DeserializeOwned>(id: &str) -> Result<T> {
178 let client = reqwest::Client::builder()
179 .user_agent(concat!("hexomc-lib/", env!("CARGO_PKG_VERSION")))
180 .build()?;
181 let response = client
182 .get(format!("{API_BASE}/modpack/{id}"))
183 .send()
184 .await?;
185 if response.status() == reqwest::StatusCode::NOT_FOUND {
186 return Err(HexoError::VersionNotFound(format!("FTB {id}")));
187 }
188 let http_error = response.error_for_status_ref().err();
189 let value = response.json::<serde_json::Value>().await;
190 if let Ok(ref value) = value {
191 if value.get("status").and_then(|s| s.as_str()) != Some("success") {
192 return Err(HexoError::VersionNotFound(format!("FTB {id}")));
193 }
194 }
195 if let Some(error) = http_error {
196 return Err(error.into());
197 }
198 parse_response(value?, id)
199}
200
201pub async fn get_ftb_pack(pack_id: u64) -> Result<FtbPack> {
203 fetch(&pack_id.to_string()).await
204}
205
206pub async fn fetch_ftb_version_manifest(
208 pack_id: u64,
209 version_id: u64,
210) -> Result<FtbVersionManifest> {
211 fetch(&format!("{pack_id}/{version_id}")).await
212}
213
214fn download_task(
215 file: &FtbFile,
216 url: &str,
217 dest: &Path,
218 fallback_sha1: Option<&str>,
219) -> DownloadTask {
220 let mut task = DownloadTask::new(url, dest);
221 if !file.sha1.is_empty() {
222 task = task.with_sha1(&file.sha1);
223 } else if let Some(sha1) = fallback_sha1 {
224 task = task.with_sha1(sha1);
225 }
226 task
227}
228
229fn resolve_files(
230 wanted: Vec<(&FtbFile, PathBuf)>,
231 files: &[CfModFile],
232 projects: &[CfMod],
233 tasks: &mut Vec<DownloadTask>,
234 manual_downloads: &mut Vec<ManualDownload>,
235) {
236 let files: HashMap<_, _> = files.iter().map(|f| (f.id, f)).collect();
237 let projects: HashMap<_, _> = projects.iter().map(|p| (p.id, p)).collect();
238 for (entry, dest) in wanted {
239 let Some(reference) = &entry.curseforge else {
240 continue;
241 };
242 let file = files
243 .get(&reference.file)
244 .filter(|f| f.mod_id == reference.project);
245 let project = projects.get(&reference.project);
246 if let Some(url) = file
247 .and_then(|f| f.download_url.as_deref())
248 .filter(|u| !u.is_empty())
249 {
250 tasks.push(download_task(
251 entry,
252 url,
253 &dest,
254 file.and_then(|f| f.sha1()),
255 ));
256 } else {
257 manual_downloads.push(ManualDownload {
258 name: project.map_or_else(|| entry.name.clone(), |p| p.name.clone()),
259 file_name: entry.name.clone(),
260 website: project
261 .and_then(|p| p.links.as_ref())
262 .and_then(|l| l.website_url.as_ref())
263 .map(|url| format!("{}/files/{}", url.trim_end_matches('/'), reference.file)),
264 dest,
265 });
266 }
267 }
268}
269
270#[allow(clippy::too_many_arguments)]
276pub async fn install_ftb_pack(
277 pack_id: u64,
278 version_id: u64,
279 instance_name: &str,
280 base_dir: &Path,
281 java_path: Option<&Path>,
282 curseforge: Option<&CurseForgeClient>,
283 include_optional: bool,
284 progress: ProgressFn,
285) -> Result<ModpackInstallResult> {
286 let pack = get_ftb_pack(pack_id).await?;
287 let manifest = fetch_ftb_version_manifest(pack_id, version_id).await?;
288 let info = manifest.info(&pack.name)?;
289 install_pack_loader(&info, instance_name, base_dir, java_path, progress.clone()).await?;
290
291 install_manifest_files(&manifest, &pack.name, instance_name, base_dir, curseforge, include_optional, progress).await
292}
293
294pub async fn install_ftb_pack_files(
299 pack_id: u64,
300 version_id: u64,
301 instance_name: &str,
302 base_dir: &Path,
303 curseforge: Option<&CurseForgeClient>,
304 include_optional: bool,
305 progress: ProgressFn,
306) -> Result<ModpackInstallResult> {
307 let pack = get_ftb_pack(pack_id).await?;
308 let manifest = fetch_ftb_version_manifest(pack_id, version_id).await?;
309 install_manifest_files(&manifest, &pack.name, instance_name, base_dir, curseforge, include_optional, progress).await
310}
311
312async fn install_manifest_files(
313 manifest: &FtbVersionManifest,
314 pack_name: &str,
315 instance_name: &str,
316 base_dir: &Path,
317 curseforge: Option<&CurseForgeClient>,
318 include_optional: bool,
319 progress: ProgressFn,
320) -> Result<ModpackInstallResult> {
321 let info = manifest.info(pack_name)?;
322
323 let game_dir = instance_game_dir(base_dir, instance_name);
324 tokio::fs::create_dir_all(&game_dir).await?;
325 let mut tasks = Vec::new();
326 let mut wanted = Vec::new();
327 let mut manual_downloads = Vec::new();
328 let mut skipped = Vec::new();
329 for file in manifest.selected_files(include_optional) {
330 let dest = safe_join(&safe_join(&game_dir, &file.path)?, &file.name)?;
331 if !file.url.is_empty() {
332 tasks.push(download_task(file, &file.url, &dest, None));
333 } else if file.curseforge.is_some() {
334 wanted.push((file, dest));
335 } else {
336 skipped.push(file.name.clone());
337 }
338 }
339
340 if !wanted.is_empty() {
341 let (files, projects) = if let Some(client) = curseforge {
342 progress(0, 0, "Resolving CurseForge files");
343 let references: Vec<_> = wanted
344 .iter()
345 .filter_map(|(f, _)| f.curseforge.as_ref())
346 .collect();
347 let mut file_ids: Vec<_> = references.iter().map(|r| r.file).collect();
348 let mut project_ids: Vec<_> = references.iter().map(|r| r.project).collect();
349 file_ids.sort_unstable();
350 file_ids.dedup();
351 project_ids.sort_unstable();
352 project_ids.dedup();
353 (
354 client.get_files(&file_ids).await?,
355 client.get_mods(&project_ids).await?,
356 )
357 } else {
358 (Vec::new(), Vec::new())
359 };
360 resolve_files(wanted, &files, &projects, &mut tasks, &mut manual_downloads);
361 }
362 download_batch(tasks, 8, move |d, t| {
363 progress(d, t, "Downloading pack files")
364 })
365 .await?;
366 Ok(ModpackInstallResult {
367 info,
368 manual_downloads,
369 skipped,
370 })
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376 use serde_json::json;
377
378 const SAMPLE: &str = r#"{
379 "status": "success", "id": 100422, "name": "1.0.1",
380 "type": "archived", "parent": 134,
381 "targets": [
382 {"name": "minecraft", "version": "1.21.1", "type": "game"},
383 {"name": "neoforge", "version": "21.1.235", "type": "modloader"},
384 {"name": "java", "version": "21.0.10+7-LTS", "type": "runtime"}
385 ],
386 "files": [{
387 "id": 3483569778, "path": "./config", "name": "dummmmmmy-common.toml",
388 "url": "https://files.feed-the-beast.com/blob/57/example.toml", "mirrors": [],
389 "sha1": "208500c1984ff314dde2e5ef1537c648e9e935af",
390 "hashes": {"sha1": "208500c1984ff314dde2e5ef1537c648e9e935af", "murmur": 2484968544},
391 "size": 1323, "clientonly": false, "serveronly": false, "optional": false,
392 "type": "config", "updated": 1784229181
393 }]
394 }"#;
395
396 fn manifest() -> FtbVersionManifest {
397 serde_json::from_str(SAMPLE).unwrap()
398 }
399
400 #[tokio::test]
401 async fn files_only_creates_game_dir_without_instance_config() {
402 let mut manifest = manifest();
403 manifest.files.clear();
404 let temp = tempfile::tempdir().unwrap();
405 let result = install_manifest_files(&manifest, "Test", "test", temp.path(), None, false, crate::no_progress()).await.unwrap();
406 assert_eq!(result.info.loader, LoaderType::NeoForge);
407 assert!(temp.path().join("instance/test/.minecraft").is_dir());
408 assert!(!temp.path().join("instance/test/instance_config.json").exists());
409 assert!(!temp.path().join("libraries").exists());
410 assert!(!temp.path().join("assets").exists());
411 }
412
413 #[test]
414 fn parse_manifest_and_pack() {
415 let manifest = manifest();
416 let info = manifest.info("FTB Skies 2: Aero").unwrap();
417 assert_eq!(info.name, "FTB Skies 2: Aero");
418 assert_eq!(info.version.as_deref(), Some("1.0.1"));
419 assert_eq!(info.mc_version, "1.21.1");
420 assert_eq!(info.loader, LoaderType::NeoForge);
421 assert_eq!(info.loader_version.as_deref(), Some("21.1.235"));
422 let file = &manifest.files[0];
423 let root = Path::new("game");
424 let dest = safe_join(&safe_join(root, &file.path).unwrap(), &file.name).unwrap();
425 assert_eq!(dest, root.join("config/dummmmmmy-common.toml"));
426 let task = download_task(file, &file.url, &dest, None);
427 assert_eq!(task.sha1.as_deref(), Some(file.sha1.as_str()));
428 assert_eq!(task.path, dest);
429 for (path, name) in [
430 ("../outside", "a.jar"),
431 ("./mods", "../../a.jar"),
432 ("/outside", "a.jar"),
433 ("./mods", "/a.jar"),
434 ] {
435 assert!(safe_join(root, path)
436 .and_then(|dir| safe_join(&dir, name))
437 .is_err());
438 }
439 let pack: FtbPack = parse_response(
440 json!({
441 "status": "success", "id": 134, "name": "FTB Skies 2: Aero",
442 "versions": [{"id": 100422, "name": "1.0.1", "type": "archived"}]
443 }),
444 "134",
445 )
446 .unwrap();
447 assert!(!pack.private);
448 assert_eq!(pack.versions[0].r#type, "archived");
449 assert!(pack.versions[0].targets.is_empty());
450 }
451
452 #[test]
453 fn filters_server_and_optional_files() {
454 let mut manifest = manifest();
455 manifest.files = (0..8)
456 .map(|flags| {
457 let mut file = manifest.files[0].clone();
458 file.serveronly = flags & 1 != 0;
459 file.optional = flags & 2 != 0;
460 file.clientonly = flags & 4 != 0;
461 file
462 })
463 .collect();
464 assert_eq!(manifest.selected_files(false).count(), 2);
465 assert_eq!(manifest.selected_files(true).count(), 4);
466 assert!(manifest.selected_files(true).all(|f| !f.serveronly));
467 assert!(manifest.selected_files(false).any(|f| f.clientonly));
468 }
469
470 #[test]
471 fn loader_selection_and_forge_prefix() {
472 let mut manifest = manifest();
473 manifest.targets.truncate(1);
474 assert_eq!(manifest.info("Test").unwrap().loader, LoaderType::Vanilla);
475 assert!(manifest.info("Test").unwrap().loader_version.is_none());
476 for (name, version, expected, bare) in [
477 ("fabric", "0.16.5", LoaderType::Fabric, "0.16.5"),
478 ("forge", "1.21.1-52.0.1", LoaderType::Forge, "52.0.1"),
479 ("neoforge", "21.1.235", LoaderType::NeoForge, "21.1.235"),
480 ] {
481 manifest.targets.insert(
482 1,
483 FtbTarget {
484 name: name.into(),
485 version: version.into(),
486 r#type: "modloader".into(),
487 },
488 );
489 let info = manifest.info("Test").unwrap();
490 assert_eq!(info.loader, expected);
491 assert_eq!(info.loader_version.as_deref(), Some(bare));
492 }
493 manifest.targets.push(FtbTarget {
494 name: "quilt".into(),
495 version: "0.26.0".into(),
496 r#type: "modloader".into(),
497 });
498 assert!(matches!(
499 manifest.info("Test"),
500 Err(HexoError::UnsupportedLoader(_))
501 ));
502 manifest.targets.clear();
503 assert!(manifest.info("Test").is_err());
504 }
505
506 #[tokio::test]
507 async fn neoforge_1_20_1_is_rejected_before_installation() {
508 let mut manifest = manifest();
509 manifest.targets[0].version = "1.20.1".into();
510 let temp = tempfile::tempdir().unwrap();
511 let result = install_manifest_files(&manifest, "Test", "test", temp.path(), None, false, crate::no_progress()).await;
512 assert!(
513 matches!(result, Err(HexoError::UnsupportedLoader(message)) if message.contains("NeoForge for 1.20.1"))
514 );
515 assert!(!temp.path().join("instance").exists());
516 }
517
518 #[test]
519 fn error_status_does_not_require_manifest_fields() {
520 for value in [json!({"status": "error"}), json!({})] {
521 assert!(matches!(
522 parse_response::<FtbVersionManifest>(value, "134/0"),
523 Err(HexoError::VersionNotFound(_))
524 ));
525 }
526 }
527
528 #[test]
529 fn curseforge_ids_accept_legacy_strings_and_numbers() {
530 for ids in [
531 json!({"project": "273430", "file": "2459131"}),
532 json!({"project": 273430, "file": 2459131}),
533 ] {
534 let ids: FtbCurseForgeRef = serde_json::from_value(ids).unwrap();
535 assert_eq!(ids.project, 273430);
536 assert_eq!(ids.file, 2459131);
537 }
538 assert!(
539 serde_json::from_value::<FtbCurseForgeRef>(json!({"project": -1, "file": 2})).is_err()
540 );
541 }
542
543 #[test]
544 fn curseforge_resolution_keeps_paths_and_missing_files() {
545 let entry: FtbFile = serde_json::from_value(json!({
546 "path": "./custom", "name": "manifest-name.jar",
547 "curseforge": {"project": 123, "file": 456}
548 }))
549 .unwrap();
550 let dest = PathBuf::from("game/custom/manifest-name.jar");
551 let mut tasks = Vec::new();
552 let mut manual = Vec::new();
553 resolve_files(
554 vec![(&entry, dest.clone())],
555 &[],
556 &[],
557 &mut tasks,
558 &mut manual,
559 );
560 assert!(tasks.is_empty());
561 assert_eq!(manual.len(), 1);
562 assert_eq!(manual[0].dest, dest);
563 assert!(manual[0].website.is_none());
564
565 let project: CfMod = serde_json::from_value(json!({
566 "id": 123, "name": "Example", "slug": "example", "summary": "", "downloadCount": 0,
567 "links": {"websiteUrl": "https://www.curseforge.com/minecraft/mc-mods/example"}
568 }))
569 .unwrap();
570 let mut file: CfModFile = serde_json::from_value(json!({
571 "id": 456, "modId": 123, "displayName": "Example", "fileName": "api-name.jar",
572 "downloadUrl": null, "gameVersions": [], "fileDate": "",
573 "hashes": [{"algo": 1, "value": "abc"}]
574 }))
575 .unwrap();
576 for url in [
577 None,
578 Some("".to_string()),
579 Some("https://example.com/mod.jar".to_string()),
580 ] {
581 file.download_url = url;
582 tasks.clear();
583 manual.clear();
584 resolve_files(
585 vec![(&entry, dest.clone())],
586 std::slice::from_ref(&file),
587 std::slice::from_ref(&project),
588 &mut tasks,
589 &mut manual,
590 );
591 if file.download_url.as_ref().is_some_and(|u| !u.is_empty()) {
592 assert!(manual.is_empty());
593 assert_eq!(tasks[0].path, dest);
594 assert_eq!(tasks[0].sha1.as_deref(), Some("abc"));
595 } else {
596 assert!(tasks.is_empty());
597 assert_eq!(manual[0].file_name, entry.name);
598 assert_eq!(
599 manual[0].website.as_deref(),
600 Some("https://www.curseforge.com/minecraft/mc-mods/example/files/456")
601 );
602 }
603 }
604 }
605}