diff options
| author | salmonik <raisersoft@proton.me> | 2026-07-11 13:32:48 +0200 |
|---|---|---|
| committer | salmonik <raisersoft@proton.me> | 2026-07-11 13:32:48 +0200 |
| commit | 7df4ceb1cf2a6edc058d28527140598ca90e1f10 (patch) | |
| tree | c8ade0334e962f0089ef4535cb07d5feaf6b857a /src/downloader.rs | |
| download | factorio-mod-downloader-7df4ceb1cf2a6edc058d28527140598ca90e1f10.tar.gz factorio-mod-downloader-7df4ceb1cf2a6edc058d28527140598ca90e1f10.zip | |
initmain
Diffstat (limited to 'src/downloader.rs')
| -rw-r--r-- | src/downloader.rs | 589 |
1 files changed, 589 insertions, 0 deletions
diff --git a/src/downloader.rs b/src/downloader.rs new file mode 100644 index 0000000..6d92166 --- /dev/null +++ b/src/downloader.rs | |||
| @@ -0,0 +1,589 @@ | |||
| 1 | use std::collections::HashSet; | ||
| 2 | use std::fs; | ||
| 3 | use std::io::{Read, Write}; | ||
| 4 | use std::path::Path; | ||
| 5 | |||
| 6 | use anyhow::{bail, Context, Result}; | ||
| 7 | use regex::Regex; | ||
| 8 | use serde::Deserialize; | ||
| 9 | |||
| 10 | // --------------------------------------------------------------------------- | ||
| 11 | // Public event type for GUI progress reporting | ||
| 12 | // --------------------------------------------------------------------------- | ||
| 13 | |||
| 14 | #[cfg(feature = "gui")] | ||
| 15 | use std::sync::mpsc; | ||
| 16 | |||
| 17 | #[cfg(feature = "gui")] | ||
| 18 | #[derive(Debug, Clone)] | ||
| 19 | pub enum Event { | ||
| 20 | Log(String), | ||
| 21 | DownloadStart { name: String, total_bytes: u64 }, | ||
| 22 | DownloadProgress { name: String, bytes: u64 }, | ||
| 23 | DownloadComplete { name: String }, | ||
| 24 | DownloadError { name: String, error: String }, | ||
| 25 | Finished { success: bool, message: String }, | ||
| 26 | } | ||
| 27 | |||
| 28 | // --------------------------------------------------------------------------- | ||
| 29 | // API response types | ||
| 30 | // --------------------------------------------------------------------------- | ||
| 31 | |||
| 32 | #[derive(Deserialize)] | ||
| 33 | pub struct ModInfo { | ||
| 34 | pub releases: Vec<Release>, | ||
| 35 | } | ||
| 36 | |||
| 37 | #[derive(Deserialize, Clone)] | ||
| 38 | pub struct Release { | ||
| 39 | pub version: String, | ||
| 40 | pub info_json: Option<InfoJson>, | ||
| 41 | } | ||
| 42 | |||
| 43 | #[derive(Deserialize, Clone)] | ||
| 44 | pub struct InfoJson { | ||
| 45 | #[serde(default)] | ||
| 46 | pub dependencies: Vec<String>, | ||
| 47 | } | ||
| 48 | |||
| 49 | // --------------------------------------------------------------------------- | ||
| 50 | // Dependency model | ||
| 51 | // --------------------------------------------------------------------------- | ||
| 52 | |||
| 53 | #[derive(Debug, Clone, PartialEq)] | ||
| 54 | pub enum DepType { | ||
| 55 | Base, | ||
| 56 | Required, | ||
| 57 | Optional, | ||
| 58 | Incompatible, | ||
| 59 | } | ||
| 60 | |||
| 61 | #[derive(Debug, Clone)] | ||
| 62 | pub struct Dependency { | ||
| 63 | pub dep_type: DepType, | ||
| 64 | pub name: String, | ||
| 65 | } | ||
| 66 | |||
| 67 | // --------------------------------------------------------------------------- | ||
| 68 | // Constants | ||
| 69 | // --------------------------------------------------------------------------- | ||
| 70 | |||
| 71 | pub const MODINFO_API: &str = "https://re146.dev/factorio/mods/modinfo"; | ||
| 72 | pub const DOWNLOAD_BASE: &str = "https://mods-storage.re146.dev"; | ||
| 73 | |||
| 74 | fn dep_regex() -> &'static Regex { | ||
| 75 | static RE: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| { | ||
| 76 | Regex::new( | ||
| 77 | r"(?i)^((?:)|(?:\?)|(?:\(\?\))|(?:!)|(?:~))(?:\s?)([%\w\s-]+)(?:\s{0,})(?:((?:=>)|(?:==)|(?:=)|(?:<=)|(?:>=)|(?:<)|(?:>)|(?:))(?:\s{0,})((\d+\.\d+\.\d+)|(\d+\.\d+)))?$", | ||
| 78 | ) | ||
| 79 | .unwrap() | ||
| 80 | }); | ||
| 81 | &RE | ||
| 82 | } | ||
| 83 | |||
| 84 | // --------------------------------------------------------------------------- | ||
| 85 | // Mod name extraction | ||
| 86 | // --------------------------------------------------------------------------- | ||
| 87 | |||
| 88 | pub fn extract_mod_name(url_or_name: &str) -> Result<String> { | ||
| 89 | let re = Regex::new(r"(?i)^https?://mods\.factorio\.com/mod/([^\s/#?]{1,})").unwrap(); | ||
| 90 | if let Some(caps) = re.captures(url_or_name) { | ||
| 91 | return Ok(caps[1].to_string()); | ||
| 92 | } | ||
| 93 | |||
| 94 | let re2 = | ||
| 95 | Regex::new(r"(?i)^https?://mods\.factorio\.com/mods/([^\s/]{1,})/([^\s/#?]{1,})").unwrap(); | ||
| 96 | if let Some(caps) = re2.captures(url_or_name) { | ||
| 97 | return Ok(caps[2].to_string()); | ||
| 98 | } | ||
| 99 | |||
| 100 | if !url_or_name.contains('/') && !url_or_name.starts_with("http") { | ||
| 101 | return Ok(url_or_name.to_string()); | ||
| 102 | } | ||
| 103 | |||
| 104 | bail!("Could not extract mod name from: {url_or_name}") | ||
| 105 | } | ||
| 106 | |||
| 107 | // --------------------------------------------------------------------------- | ||
| 108 | // API helpers | ||
| 109 | // --------------------------------------------------------------------------- | ||
| 110 | |||
| 111 | pub fn fetch_modinfo(mod_name: &str) -> Result<ModInfo> { | ||
| 112 | let url = format!("{MODINFO_API}?id={mod_name}"); | ||
| 113 | let resp = ureq::get(&url) | ||
| 114 | .header("Accept", "application/json") | ||
| 115 | .call() | ||
| 116 | .with_context(|| format!("Failed to fetch mod info for {mod_name}"))?; | ||
| 117 | |||
| 118 | if resp.status().as_u16() == 404 { | ||
| 119 | bail!("Mod '{mod_name}' not found on the portal"); | ||
| 120 | } | ||
| 121 | |||
| 122 | let body_text = resp | ||
| 123 | .into_body() | ||
| 124 | .read_to_string() | ||
| 125 | .with_context(|| format!("Failed to read response body for {mod_name}"))?; | ||
| 126 | |||
| 127 | let modinfo: ModInfo = serde_json::from_str(&body_text) | ||
| 128 | .with_context(|| format!("Failed to parse mod info JSON for {mod_name}"))?; | ||
| 129 | |||
| 130 | if modinfo.releases.is_empty() { | ||
| 131 | bail!("No releases found for '{mod_name}'"); | ||
| 132 | } | ||
| 133 | |||
| 134 | Ok(modinfo) | ||
| 135 | } | ||
| 136 | |||
| 137 | /// Fetch available versions for a mod. Returns (version, display_label) pairs. | ||
| 138 | #[cfg(feature = "gui")] | ||
| 139 | pub fn fetch_versions(mod_name: &str) -> Result<Vec<(String, String)>> { | ||
| 140 | let modinfo = fetch_modinfo(mod_name)?; | ||
| 141 | let total = modinfo.releases.len(); | ||
| 142 | |||
| 143 | let mut versions: Vec<(String, String)> = modinfo | ||
| 144 | .releases | ||
| 145 | .iter() | ||
| 146 | .enumerate() | ||
| 147 | .filter_map(|(i, r)| { | ||
| 148 | let v = r.version.clone(); | ||
| 149 | if v.is_empty() { | ||
| 150 | return None; | ||
| 151 | } | ||
| 152 | let is_latest = i == total - 1; | ||
| 153 | let factorio_ver = r | ||
| 154 | .info_json | ||
| 155 | .as_ref() | ||
| 156 | .and_then(|j| j.dependencies.first()) | ||
| 157 | .and_then(|d| { | ||
| 158 | parse_dependency_string(d).and_then(|dep| { | ||
| 159 | if dep.dep_type == DepType::Base && !dep.name.is_empty() { | ||
| 160 | Some(dep.name) | ||
| 161 | } else { | ||
| 162 | None | ||
| 163 | } | ||
| 164 | }) | ||
| 165 | }) | ||
| 166 | .unwrap_or_default(); | ||
| 167 | |||
| 168 | let mut label = v.clone(); | ||
| 169 | if is_latest { | ||
| 170 | label.push_str(" (latest)"); | ||
| 171 | } | ||
| 172 | if !factorio_ver.is_empty() { | ||
| 173 | label.push_str(&format!(" for {}", factorio_ver)); | ||
| 174 | } | ||
| 175 | Some((v, label)) | ||
| 176 | }) | ||
| 177 | .collect(); | ||
| 178 | |||
| 179 | versions.reverse(); | ||
| 180 | Ok(versions) | ||
| 181 | } | ||
| 182 | |||
| 183 | pub fn find_release<'a>(modinfo: &'a ModInfo, target_version: Option<&str>) -> Option<&'a Release> { | ||
| 184 | match target_version { | ||
| 185 | Some(v) => modinfo.releases.iter().find(|r| r.version == v), | ||
| 186 | None => modinfo.releases.last(), | ||
| 187 | } | ||
| 188 | } | ||
| 189 | |||
| 190 | // --------------------------------------------------------------------------- | ||
| 191 | // Dependency parsing | ||
| 192 | // --------------------------------------------------------------------------- | ||
| 193 | |||
| 194 | pub fn parse_dependency_string(input: &str) -> Option<Dependency> { | ||
| 195 | let re = dep_regex(); | ||
| 196 | let caps = re.captures(input)?; | ||
| 197 | |||
| 198 | let name = caps[2].trim().to_string(); | ||
| 199 | |||
| 200 | if name.eq_ignore_ascii_case("base") { | ||
| 201 | return Some(Dependency { | ||
| 202 | dep_type: DepType::Base, | ||
| 203 | name: "base".into(), | ||
| 204 | }); | ||
| 205 | } | ||
| 206 | |||
| 207 | let dep_type = match &caps[1] { | ||
| 208 | "?" | "(?)" => DepType::Optional, | ||
| 209 | "!" => DepType::Incompatible, | ||
| 210 | _ => DepType::Required, | ||
| 211 | }; | ||
| 212 | |||
| 213 | Some(Dependency { | ||
| 214 | dep_type, | ||
| 215 | name: name.trim().to_string(), | ||
| 216 | }) | ||
| 217 | } | ||
| 218 | |||
| 219 | pub fn parse_dependencies(deps: &[String]) -> (Vec<Dependency>, Vec<Dependency>, Vec<Dependency>) { | ||
| 220 | let mut required = Vec::new(); | ||
| 221 | let mut optional = Vec::new(); | ||
| 222 | let mut incompatible = Vec::new(); | ||
| 223 | |||
| 224 | for dep_str in deps.iter().rev() { | ||
| 225 | if let Some(dep) = parse_dependency_string(dep_str) { | ||
| 226 | match dep.dep_type { | ||
| 227 | DepType::Base => {} | ||
| 228 | DepType::Required => required.push(dep), | ||
| 229 | DepType::Optional => optional.push(dep), | ||
| 230 | DepType::Incompatible => incompatible.push(dep), | ||
| 231 | } | ||
| 232 | } | ||
| 233 | } | ||
| 234 | |||
| 235 | (required, optional, incompatible) | ||
| 236 | } | ||
| 237 | |||
| 238 | // --------------------------------------------------------------------------- | ||
| 239 | // GUI download (channel-based) | ||
| 240 | // --------------------------------------------------------------------------- | ||
| 241 | |||
| 242 | #[cfg(feature = "gui")] | ||
| 243 | fn gui_download_file( | ||
| 244 | url: &str, | ||
| 245 | file_path: &Path, | ||
| 246 | display_name: &str, | ||
| 247 | tx: &mpsc::Sender<Event>, | ||
| 248 | ) -> Result<()> { | ||
| 249 | let resp = ureq::get(url) | ||
| 250 | .call() | ||
| 251 | .with_context(|| format!("Failed to download {display_name}"))?; | ||
| 252 | |||
| 253 | let status = resp.status().as_u16(); | ||
| 254 | if status == 404 { | ||
| 255 | bail!("File not found on storage: {display_name}"); | ||
| 256 | } | ||
| 257 | if !(200..300).contains(&status) { | ||
| 258 | bail!("HTTP {status} while downloading {display_name}"); | ||
| 259 | } | ||
| 260 | |||
| 261 | let total_size: u64 = resp | ||
| 262 | .headers() | ||
| 263 | .get("content-length") | ||
| 264 | .and_then(|v| v.to_str().ok()) | ||
| 265 | .and_then(|v| v.parse().ok()) | ||
| 266 | .unwrap_or(0); | ||
| 267 | |||
| 268 | let _ = tx.send(Event::DownloadStart { | ||
| 269 | name: display_name.to_string(), | ||
| 270 | total_bytes: total_size, | ||
| 271 | }); | ||
| 272 | |||
| 273 | let mut reader = resp.into_body().into_reader(); | ||
| 274 | let mut file = fs::File::create(file_path) | ||
| 275 | .with_context(|| format!("Failed to create file {}", file_path.display()))?; | ||
| 276 | |||
| 277 | let mut buf = [0u8; 256 * 1024]; | ||
| 278 | let mut downloaded: u64 = 0; | ||
| 279 | loop { | ||
| 280 | let n = reader | ||
| 281 | .read(&mut buf) | ||
| 282 | .with_context(|| format!("Read error while downloading {display_name}"))?; | ||
| 283 | if n == 0 { | ||
| 284 | break; | ||
| 285 | } | ||
| 286 | file.write_all(&buf[..n]) | ||
| 287 | .with_context(|| format!("Write error for {}", file_path.display()))?; | ||
| 288 | downloaded += n as u64; | ||
| 289 | let _ = tx.send(Event::DownloadProgress { | ||
| 290 | name: display_name.to_string(), | ||
| 291 | bytes: downloaded, | ||
| 292 | }); | ||
| 293 | } | ||
| 294 | |||
| 295 | let _ = tx.send(Event::DownloadComplete { | ||
| 296 | name: display_name.to_string(), | ||
| 297 | }); | ||
| 298 | Ok(()) | ||
| 299 | } | ||
| 300 | |||
| 301 | #[cfg(feature = "gui")] | ||
| 302 | fn gui_download_mod_with_deps( | ||
| 303 | mod_name: &str, | ||
| 304 | output: &Path, | ||
| 305 | target_version: Option<&str>, | ||
| 306 | include_optional: bool, | ||
| 307 | downloaded: &mut HashSet<String>, | ||
| 308 | analyzed: &mut HashSet<String>, | ||
| 309 | tx: &mpsc::Sender<Event>, | ||
| 310 | ) -> Result<()> { | ||
| 311 | if analyzed.contains(mod_name) { | ||
| 312 | return Ok(()); | ||
| 313 | } | ||
| 314 | |||
| 315 | if mod_name == "space-age" { | ||
| 316 | let _ = tx.send(Event::Log(format!( | ||
| 317 | "Skipping reserved dependency '{mod_name}'" | ||
| 318 | ))); | ||
| 319 | return Ok(()); | ||
| 320 | } | ||
| 321 | |||
| 322 | let modinfo = fetch_modinfo(mod_name)?; | ||
| 323 | |||
| 324 | let release = find_release(&modinfo, target_version) | ||
| 325 | .with_context(|| format!("Version not found for '{mod_name}'"))?; | ||
| 326 | |||
| 327 | let version = &release.version; | ||
| 328 | let _ = tx.send(Event::Log(format!("Processing: {mod_name} v{version}"))); | ||
| 329 | analyzed.insert(mod_name.to_string()); | ||
| 330 | |||
| 331 | let file_name = format!("{mod_name}_{version}.zip"); | ||
| 332 | let file_path = output.join(&file_name); | ||
| 333 | |||
| 334 | if !file_path.exists() { | ||
| 335 | fs::create_dir_all(output)?; | ||
| 336 | let download_url = format!("{DOWNLOAD_BASE}/{mod_name}/{version}.zip"); | ||
| 337 | |||
| 338 | if let Err(e) = gui_download_file(&download_url, &file_path, &file_name, tx) { | ||
| 339 | let _ = tx.send(Event::DownloadError { | ||
| 340 | name: file_name.clone(), | ||
| 341 | error: e.to_string(), | ||
| 342 | }); | ||
| 343 | return Err(e); | ||
| 344 | } | ||
| 345 | downloaded.insert(mod_name.to_string()); | ||
| 346 | } else { | ||
| 347 | let _ = tx.send(Event::Log(format!("Already downloaded: {file_name}"))); | ||
| 348 | } | ||
| 349 | |||
| 350 | if let Some(info_json) = &release.info_json { | ||
| 351 | let (required, optional, _incompatible) = parse_dependencies(&info_json.dependencies); | ||
| 352 | |||
| 353 | let mut deps = required; | ||
| 354 | if include_optional { | ||
| 355 | deps.extend(optional); | ||
| 356 | } | ||
| 357 | |||
| 358 | for dep in &deps { | ||
| 359 | if dep.name == "base" || downloaded.contains(&dep.name) || analyzed.contains(&dep.name) | ||
| 360 | { | ||
| 361 | continue; | ||
| 362 | } | ||
| 363 | let _ = tx.send(Event::Log(format!("-> Fetching dependency: {}", dep.name))); | ||
| 364 | if let Err(e) = gui_download_mod_with_deps( | ||
| 365 | &dep.name, | ||
| 366 | output, | ||
| 367 | None, | ||
| 368 | include_optional, | ||
| 369 | downloaded, | ||
| 370 | analyzed, | ||
| 371 | tx, | ||
| 372 | ) { | ||
| 373 | let _ = tx.send(Event::Log(format!( | ||
| 374 | "Error processing dependency {}: {}", | ||
| 375 | dep.name, e | ||
| 376 | ))); | ||
| 377 | } | ||
| 378 | } | ||
| 379 | } | ||
| 380 | |||
| 381 | Ok(()) | ||
| 382 | } | ||
| 383 | |||
| 384 | /// Entry point for GUI downloads. Runs the full pipeline and sends events via the channel. | ||
| 385 | #[cfg(feature = "gui")] | ||
| 386 | pub fn run_gui_download( | ||
| 387 | mod_url: &str, | ||
| 388 | output: &Path, | ||
| 389 | target_version: Option<String>, | ||
| 390 | include_optional: bool, | ||
| 391 | tx: mpsc::Sender<Event>, | ||
| 392 | ) { | ||
| 393 | let result = (|| -> Result<()> { | ||
| 394 | let mod_name = extract_mod_name(mod_url)?; | ||
| 395 | let _ = tx.send(Event::Log(format!("Starting download: {mod_name}"))); | ||
| 396 | |||
| 397 | let mut downloaded = HashSet::new(); | ||
| 398 | let mut analyzed = HashSet::new(); | ||
| 399 | |||
| 400 | gui_download_mod_with_deps( | ||
| 401 | &mod_name, | ||
| 402 | output, | ||
| 403 | target_version.as_deref(), | ||
| 404 | include_optional, | ||
| 405 | &mut downloaded, | ||
| 406 | &mut analyzed, | ||
| 407 | &tx, | ||
| 408 | )?; | ||
| 409 | |||
| 410 | Ok(()) | ||
| 411 | })(); | ||
| 412 | |||
| 413 | match result { | ||
| 414 | Ok(()) => { | ||
| 415 | let _ = tx.send(Event::Finished { | ||
| 416 | success: true, | ||
| 417 | message: "All mods downloaded successfully.".into(), | ||
| 418 | }); | ||
| 419 | } | ||
| 420 | Err(e) => { | ||
| 421 | let _ = tx.send(Event::Finished { | ||
| 422 | success: false, | ||
| 423 | message: format!("Download failed: {e}"), | ||
| 424 | }); | ||
| 425 | } | ||
| 426 | } | ||
| 427 | } | ||
| 428 | |||
| 429 | // --------------------------------------------------------------------------- | ||
| 430 | // CLI download (prints progress to stdout) | ||
| 431 | // --------------------------------------------------------------------------- | ||
| 432 | |||
| 433 | #[cfg(not(feature = "gui"))] | ||
| 434 | fn cli_download_file(url: &str, file_path: &Path, display_name: &str) -> Result<()> { | ||
| 435 | let resp = ureq::get(url) | ||
| 436 | .call() | ||
| 437 | .with_context(|| format!("Failed to download {display_name}"))?; | ||
| 438 | |||
| 439 | let status = resp.status().as_u16(); | ||
| 440 | if status == 404 { | ||
| 441 | bail!("File not found on storage: {display_name}"); | ||
| 442 | } | ||
| 443 | if !(200..300).contains(&status) { | ||
| 444 | bail!("HTTP {status} while downloading {display_name}"); | ||
| 445 | } | ||
| 446 | |||
| 447 | let total_size: u64 = resp | ||
| 448 | .headers() | ||
| 449 | .get("content-length") | ||
| 450 | .and_then(|v| v.to_str().ok()) | ||
| 451 | .and_then(|v| v.parse().ok()) | ||
| 452 | .unwrap_or(0); | ||
| 453 | |||
| 454 | println!( | ||
| 455 | " Downloading: {display_name} ({:.1} MB)", | ||
| 456 | total_size as f64 / 1_048_576.0 | ||
| 457 | ); | ||
| 458 | |||
| 459 | let mut reader = resp.into_body().into_reader(); | ||
| 460 | let mut file = fs::File::create(file_path) | ||
| 461 | .with_context(|| format!("Failed to create file {}", file_path.display()))?; | ||
| 462 | |||
| 463 | let mut buf = [0u8; 256 * 1024]; | ||
| 464 | let mut downloaded: u64 = 0; | ||
| 465 | let mut last_pct: u32 = 0; | ||
| 466 | loop { | ||
| 467 | let n = reader | ||
| 468 | .read(&mut buf) | ||
| 469 | .with_context(|| format!("Read error while downloading {display_name}"))?; | ||
| 470 | if n == 0 { | ||
| 471 | break; | ||
| 472 | } | ||
| 473 | file.write_all(&buf[..n]) | ||
| 474 | .with_context(|| format!("Write error for {}", file_path.display()))?; | ||
| 475 | downloaded += n as u64; | ||
| 476 | |||
| 477 | if total_size > 0 { | ||
| 478 | let pct = (downloaded * 100 / total_size) as u32; | ||
| 479 | if pct / 10 != last_pct / 10 { | ||
| 480 | last_pct = pct; | ||
| 481 | print!("\r {} {}%", display_name, pct); | ||
| 482 | let _ = std::io::Write::flush(&mut std::io::stdout()); | ||
| 483 | } | ||
| 484 | } | ||
| 485 | } | ||
| 486 | println!("\r {} Done.", display_name); | ||
| 487 | Ok(()) | ||
| 488 | } | ||
| 489 | |||
| 490 | #[cfg(not(feature = "gui"))] | ||
| 491 | fn cli_download_mod_with_deps( | ||
| 492 | mod_name: &str, | ||
| 493 | output: &Path, | ||
| 494 | target_version: Option<&str>, | ||
| 495 | include_optional: bool, | ||
| 496 | downloaded: &mut HashSet<String>, | ||
| 497 | analyzed: &mut HashSet<String>, | ||
| 498 | ) -> Result<()> { | ||
| 499 | if analyzed.contains(mod_name) { | ||
| 500 | return Ok(()); | ||
| 501 | } | ||
| 502 | |||
| 503 | if mod_name == "space-age" { | ||
| 504 | println!("Skipping reserved dependency '{mod_name}'"); | ||
| 505 | return Ok(()); | ||
| 506 | } | ||
| 507 | |||
| 508 | let modinfo = fetch_modinfo(mod_name)?; | ||
| 509 | |||
| 510 | let release = find_release(&modinfo, target_version) | ||
| 511 | .with_context(|| format!("Version not found for '{mod_name}'"))?; | ||
| 512 | |||
| 513 | let version = &release.version; | ||
| 514 | println!("Processing: {mod_name} v{version}"); | ||
| 515 | analyzed.insert(mod_name.to_string()); | ||
| 516 | |||
| 517 | let file_name = format!("{mod_name}_{version}.zip"); | ||
| 518 | let file_path = output.join(&file_name); | ||
| 519 | |||
| 520 | if !file_path.exists() { | ||
| 521 | fs::create_dir_all(output)?; | ||
| 522 | let download_url = format!("{DOWNLOAD_BASE}/{mod_name}/{version}.zip"); | ||
| 523 | |||
| 524 | if let Err(e) = cli_download_file(&download_url, &file_path, &file_name) { | ||
| 525 | eprintln!("Error downloading {file_name}: {e}"); | ||
| 526 | return Err(e); | ||
| 527 | } | ||
| 528 | downloaded.insert(mod_name.to_string()); | ||
| 529 | } else { | ||
| 530 | println!("Already downloaded: {file_name}"); | ||
| 531 | } | ||
| 532 | |||
| 533 | if let Some(info_json) = &release.info_json { | ||
| 534 | let (required, optional, _incompatible) = parse_dependencies(&info_json.dependencies); | ||
| 535 | |||
| 536 | let mut deps = required; | ||
| 537 | if include_optional { | ||
| 538 | deps.extend(optional); | ||
| 539 | } | ||
| 540 | |||
| 541 | for dep in &deps { | ||
| 542 | if dep.name == "base" || downloaded.contains(&dep.name) || analyzed.contains(&dep.name) | ||
| 543 | { | ||
| 544 | continue; | ||
| 545 | } | ||
| 546 | println!("-> Fetching dependency: {}", dep.name); | ||
| 547 | if let Err(e) = cli_download_mod_with_deps( | ||
| 548 | &dep.name, | ||
| 549 | output, | ||
| 550 | None, | ||
| 551 | include_optional, | ||
| 552 | downloaded, | ||
| 553 | analyzed, | ||
| 554 | ) { | ||
| 555 | eprintln!("Error processing dependency {}: {}", dep.name, e); | ||
| 556 | } | ||
| 557 | } | ||
| 558 | } | ||
| 559 | |||
| 560 | Ok(()) | ||
| 561 | } | ||
| 562 | |||
| 563 | /// Entry point for CLI downloads. Prints progress to stdout. | ||
| 564 | #[cfg(not(feature = "gui"))] | ||
| 565 | pub fn run_cli_download( | ||
| 566 | mod_url: &str, | ||
| 567 | output: &Path, | ||
| 568 | target_version: Option<&str>, | ||
| 569 | include_optional: bool, | ||
| 570 | ) -> Result<()> { | ||
| 571 | let mod_name = extract_mod_name(mod_url)?; | ||
| 572 | println!("Starting download: {mod_name}"); | ||
| 573 | println!("Output directory: {}", output.display()); | ||
| 574 | |||
| 575 | let mut downloaded = HashSet::new(); | ||
| 576 | let mut analyzed = HashSet::new(); | ||
| 577 | |||
| 578 | cli_download_mod_with_deps( | ||
| 579 | &mod_name, | ||
| 580 | output, | ||
| 581 | target_version, | ||
| 582 | include_optional, | ||
| 583 | &mut downloaded, | ||
| 584 | &mut analyzed, | ||
| 585 | )?; | ||
| 586 | |||
| 587 | println!("\nAll mods downloaded successfully."); | ||
| 588 | Ok(()) | ||
| 589 | } | ||
