summaryrefslogtreecommitdiffstats
path: root/src/downloader.rs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/downloader.rs589
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 @@
1use std::collections::HashSet;
2use std::fs;
3use std::io::{Read, Write};
4use std::path::Path;
5
6use anyhow::{bail, Context, Result};
7use regex::Regex;
8use serde::Deserialize;
9
10// ---------------------------------------------------------------------------
11// Public event type for GUI progress reporting
12// ---------------------------------------------------------------------------
13
14#[cfg(feature = "gui")]
15use std::sync::mpsc;
16
17#[cfg(feature = "gui")]
18#[derive(Debug, Clone)]
19pub 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)]
33pub struct ModInfo {
34 pub releases: Vec<Release>,
35}
36
37#[derive(Deserialize, Clone)]
38pub struct Release {
39 pub version: String,
40 pub info_json: Option<InfoJson>,
41}
42
43#[derive(Deserialize, Clone)]
44pub struct InfoJson {
45 #[serde(default)]
46 pub dependencies: Vec<String>,
47}
48
49// ---------------------------------------------------------------------------
50// Dependency model
51// ---------------------------------------------------------------------------
52
53#[derive(Debug, Clone, PartialEq)]
54pub enum DepType {
55 Base,
56 Required,
57 Optional,
58 Incompatible,
59}
60
61#[derive(Debug, Clone)]
62pub struct Dependency {
63 pub dep_type: DepType,
64 pub name: String,
65}
66
67// ---------------------------------------------------------------------------
68// Constants
69// ---------------------------------------------------------------------------
70
71pub const MODINFO_API: &str = "https://re146.dev/factorio/mods/modinfo";
72pub const DOWNLOAD_BASE: &str = "https://mods-storage.re146.dev";
73
74fn 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
88pub 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
111pub 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")]
139pub 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
183pub 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
194pub 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
219pub 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")]
243fn 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")]
302fn 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")]
386pub 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"))]
434fn 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"))]
491fn 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"))]
565pub 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}