From 7df4ceb1cf2a6edc058d28527140598ca90e1f10 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sat, 11 Jul 2026 13:32:48 +0200 Subject: init --- src/downloader.rs | 589 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 589 insertions(+) create mode 100644 src/downloader.rs (limited to 'src/downloader.rs') 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 @@ +use std::collections::HashSet; +use std::fs; +use std::io::{Read, Write}; +use std::path::Path; + +use anyhow::{bail, Context, Result}; +use regex::Regex; +use serde::Deserialize; + +// --------------------------------------------------------------------------- +// Public event type for GUI progress reporting +// --------------------------------------------------------------------------- + +#[cfg(feature = "gui")] +use std::sync::mpsc; + +#[cfg(feature = "gui")] +#[derive(Debug, Clone)] +pub enum Event { + Log(String), + DownloadStart { name: String, total_bytes: u64 }, + DownloadProgress { name: String, bytes: u64 }, + DownloadComplete { name: String }, + DownloadError { name: String, error: String }, + Finished { success: bool, message: String }, +} + +// --------------------------------------------------------------------------- +// API response types +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +pub struct ModInfo { + pub releases: Vec, +} + +#[derive(Deserialize, Clone)] +pub struct Release { + pub version: String, + pub info_json: Option, +} + +#[derive(Deserialize, Clone)] +pub struct InfoJson { + #[serde(default)] + pub dependencies: Vec, +} + +// --------------------------------------------------------------------------- +// Dependency model +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq)] +pub enum DepType { + Base, + Required, + Optional, + Incompatible, +} + +#[derive(Debug, Clone)] +pub struct Dependency { + pub dep_type: DepType, + pub name: String, +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +pub const MODINFO_API: &str = "https://re146.dev/factorio/mods/modinfo"; +pub const DOWNLOAD_BASE: &str = "https://mods-storage.re146.dev"; + +fn dep_regex() -> &'static Regex { + static RE: std::sync::LazyLock = std::sync::LazyLock::new(|| { + Regex::new( + r"(?i)^((?:)|(?:\?)|(?:\(\?\))|(?:!)|(?:~))(?:\s?)([%\w\s-]+)(?:\s{0,})(?:((?:=>)|(?:==)|(?:=)|(?:<=)|(?:>=)|(?:<)|(?:>)|(?:))(?:\s{0,})((\d+\.\d+\.\d+)|(\d+\.\d+)))?$", + ) + .unwrap() + }); + &RE +} + +// --------------------------------------------------------------------------- +// Mod name extraction +// --------------------------------------------------------------------------- + +pub fn extract_mod_name(url_or_name: &str) -> Result { + let re = Regex::new(r"(?i)^https?://mods\.factorio\.com/mod/([^\s/#?]{1,})").unwrap(); + if let Some(caps) = re.captures(url_or_name) { + return Ok(caps[1].to_string()); + } + + let re2 = + Regex::new(r"(?i)^https?://mods\.factorio\.com/mods/([^\s/]{1,})/([^\s/#?]{1,})").unwrap(); + if let Some(caps) = re2.captures(url_or_name) { + return Ok(caps[2].to_string()); + } + + if !url_or_name.contains('/') && !url_or_name.starts_with("http") { + return Ok(url_or_name.to_string()); + } + + bail!("Could not extract mod name from: {url_or_name}") +} + +// --------------------------------------------------------------------------- +// API helpers +// --------------------------------------------------------------------------- + +pub fn fetch_modinfo(mod_name: &str) -> Result { + let url = format!("{MODINFO_API}?id={mod_name}"); + let resp = ureq::get(&url) + .header("Accept", "application/json") + .call() + .with_context(|| format!("Failed to fetch mod info for {mod_name}"))?; + + if resp.status().as_u16() == 404 { + bail!("Mod '{mod_name}' not found on the portal"); + } + + let body_text = resp + .into_body() + .read_to_string() + .with_context(|| format!("Failed to read response body for {mod_name}"))?; + + let modinfo: ModInfo = serde_json::from_str(&body_text) + .with_context(|| format!("Failed to parse mod info JSON for {mod_name}"))?; + + if modinfo.releases.is_empty() { + bail!("No releases found for '{mod_name}'"); + } + + Ok(modinfo) +} + +/// Fetch available versions for a mod. Returns (version, display_label) pairs. +#[cfg(feature = "gui")] +pub fn fetch_versions(mod_name: &str) -> Result> { + let modinfo = fetch_modinfo(mod_name)?; + let total = modinfo.releases.len(); + + let mut versions: Vec<(String, String)> = modinfo + .releases + .iter() + .enumerate() + .filter_map(|(i, r)| { + let v = r.version.clone(); + if v.is_empty() { + return None; + } + let is_latest = i == total - 1; + let factorio_ver = r + .info_json + .as_ref() + .and_then(|j| j.dependencies.first()) + .and_then(|d| { + parse_dependency_string(d).and_then(|dep| { + if dep.dep_type == DepType::Base && !dep.name.is_empty() { + Some(dep.name) + } else { + None + } + }) + }) + .unwrap_or_default(); + + let mut label = v.clone(); + if is_latest { + label.push_str(" (latest)"); + } + if !factorio_ver.is_empty() { + label.push_str(&format!(" for {}", factorio_ver)); + } + Some((v, label)) + }) + .collect(); + + versions.reverse(); + Ok(versions) +} + +pub fn find_release<'a>(modinfo: &'a ModInfo, target_version: Option<&str>) -> Option<&'a Release> { + match target_version { + Some(v) => modinfo.releases.iter().find(|r| r.version == v), + None => modinfo.releases.last(), + } +} + +// --------------------------------------------------------------------------- +// Dependency parsing +// --------------------------------------------------------------------------- + +pub fn parse_dependency_string(input: &str) -> Option { + let re = dep_regex(); + let caps = re.captures(input)?; + + let name = caps[2].trim().to_string(); + + if name.eq_ignore_ascii_case("base") { + return Some(Dependency { + dep_type: DepType::Base, + name: "base".into(), + }); + } + + let dep_type = match &caps[1] { + "?" | "(?)" => DepType::Optional, + "!" => DepType::Incompatible, + _ => DepType::Required, + }; + + Some(Dependency { + dep_type, + name: name.trim().to_string(), + }) +} + +pub fn parse_dependencies(deps: &[String]) -> (Vec, Vec, Vec) { + let mut required = Vec::new(); + let mut optional = Vec::new(); + let mut incompatible = Vec::new(); + + for dep_str in deps.iter().rev() { + if let Some(dep) = parse_dependency_string(dep_str) { + match dep.dep_type { + DepType::Base => {} + DepType::Required => required.push(dep), + DepType::Optional => optional.push(dep), + DepType::Incompatible => incompatible.push(dep), + } + } + } + + (required, optional, incompatible) +} + +// --------------------------------------------------------------------------- +// GUI download (channel-based) +// --------------------------------------------------------------------------- + +#[cfg(feature = "gui")] +fn gui_download_file( + url: &str, + file_path: &Path, + display_name: &str, + tx: &mpsc::Sender, +) -> Result<()> { + let resp = ureq::get(url) + .call() + .with_context(|| format!("Failed to download {display_name}"))?; + + let status = resp.status().as_u16(); + if status == 404 { + bail!("File not found on storage: {display_name}"); + } + if !(200..300).contains(&status) { + bail!("HTTP {status} while downloading {display_name}"); + } + + let total_size: u64 = resp + .headers() + .get("content-length") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + + let _ = tx.send(Event::DownloadStart { + name: display_name.to_string(), + total_bytes: total_size, + }); + + let mut reader = resp.into_body().into_reader(); + let mut file = fs::File::create(file_path) + .with_context(|| format!("Failed to create file {}", file_path.display()))?; + + let mut buf = [0u8; 256 * 1024]; + let mut downloaded: u64 = 0; + loop { + let n = reader + .read(&mut buf) + .with_context(|| format!("Read error while downloading {display_name}"))?; + if n == 0 { + break; + } + file.write_all(&buf[..n]) + .with_context(|| format!("Write error for {}", file_path.display()))?; + downloaded += n as u64; + let _ = tx.send(Event::DownloadProgress { + name: display_name.to_string(), + bytes: downloaded, + }); + } + + let _ = tx.send(Event::DownloadComplete { + name: display_name.to_string(), + }); + Ok(()) +} + +#[cfg(feature = "gui")] +fn gui_download_mod_with_deps( + mod_name: &str, + output: &Path, + target_version: Option<&str>, + include_optional: bool, + downloaded: &mut HashSet, + analyzed: &mut HashSet, + tx: &mpsc::Sender, +) -> Result<()> { + if analyzed.contains(mod_name) { + return Ok(()); + } + + if mod_name == "space-age" { + let _ = tx.send(Event::Log(format!( + "Skipping reserved dependency '{mod_name}'" + ))); + return Ok(()); + } + + let modinfo = fetch_modinfo(mod_name)?; + + let release = find_release(&modinfo, target_version) + .with_context(|| format!("Version not found for '{mod_name}'"))?; + + let version = &release.version; + let _ = tx.send(Event::Log(format!("Processing: {mod_name} v{version}"))); + analyzed.insert(mod_name.to_string()); + + let file_name = format!("{mod_name}_{version}.zip"); + let file_path = output.join(&file_name); + + if !file_path.exists() { + fs::create_dir_all(output)?; + let download_url = format!("{DOWNLOAD_BASE}/{mod_name}/{version}.zip"); + + if let Err(e) = gui_download_file(&download_url, &file_path, &file_name, tx) { + let _ = tx.send(Event::DownloadError { + name: file_name.clone(), + error: e.to_string(), + }); + return Err(e); + } + downloaded.insert(mod_name.to_string()); + } else { + let _ = tx.send(Event::Log(format!("Already downloaded: {file_name}"))); + } + + if let Some(info_json) = &release.info_json { + let (required, optional, _incompatible) = parse_dependencies(&info_json.dependencies); + + let mut deps = required; + if include_optional { + deps.extend(optional); + } + + for dep in &deps { + if dep.name == "base" || downloaded.contains(&dep.name) || analyzed.contains(&dep.name) + { + continue; + } + let _ = tx.send(Event::Log(format!("-> Fetching dependency: {}", dep.name))); + if let Err(e) = gui_download_mod_with_deps( + &dep.name, + output, + None, + include_optional, + downloaded, + analyzed, + tx, + ) { + let _ = tx.send(Event::Log(format!( + "Error processing dependency {}: {}", + dep.name, e + ))); + } + } + } + + Ok(()) +} + +/// Entry point for GUI downloads. Runs the full pipeline and sends events via the channel. +#[cfg(feature = "gui")] +pub fn run_gui_download( + mod_url: &str, + output: &Path, + target_version: Option, + include_optional: bool, + tx: mpsc::Sender, +) { + let result = (|| -> Result<()> { + let mod_name = extract_mod_name(mod_url)?; + let _ = tx.send(Event::Log(format!("Starting download: {mod_name}"))); + + let mut downloaded = HashSet::new(); + let mut analyzed = HashSet::new(); + + gui_download_mod_with_deps( + &mod_name, + output, + target_version.as_deref(), + include_optional, + &mut downloaded, + &mut analyzed, + &tx, + )?; + + Ok(()) + })(); + + match result { + Ok(()) => { + let _ = tx.send(Event::Finished { + success: true, + message: "All mods downloaded successfully.".into(), + }); + } + Err(e) => { + let _ = tx.send(Event::Finished { + success: false, + message: format!("Download failed: {e}"), + }); + } + } +} + +// --------------------------------------------------------------------------- +// CLI download (prints progress to stdout) +// --------------------------------------------------------------------------- + +#[cfg(not(feature = "gui"))] +fn cli_download_file(url: &str, file_path: &Path, display_name: &str) -> Result<()> { + let resp = ureq::get(url) + .call() + .with_context(|| format!("Failed to download {display_name}"))?; + + let status = resp.status().as_u16(); + if status == 404 { + bail!("File not found on storage: {display_name}"); + } + if !(200..300).contains(&status) { + bail!("HTTP {status} while downloading {display_name}"); + } + + let total_size: u64 = resp + .headers() + .get("content-length") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + + println!( + " Downloading: {display_name} ({:.1} MB)", + total_size as f64 / 1_048_576.0 + ); + + let mut reader = resp.into_body().into_reader(); + let mut file = fs::File::create(file_path) + .with_context(|| format!("Failed to create file {}", file_path.display()))?; + + let mut buf = [0u8; 256 * 1024]; + let mut downloaded: u64 = 0; + let mut last_pct: u32 = 0; + loop { + let n = reader + .read(&mut buf) + .with_context(|| format!("Read error while downloading {display_name}"))?; + if n == 0 { + break; + } + file.write_all(&buf[..n]) + .with_context(|| format!("Write error for {}", file_path.display()))?; + downloaded += n as u64; + + if total_size > 0 { + let pct = (downloaded * 100 / total_size) as u32; + if pct / 10 != last_pct / 10 { + last_pct = pct; + print!("\r {} {}%", display_name, pct); + let _ = std::io::Write::flush(&mut std::io::stdout()); + } + } + } + println!("\r {} Done.", display_name); + Ok(()) +} + +#[cfg(not(feature = "gui"))] +fn cli_download_mod_with_deps( + mod_name: &str, + output: &Path, + target_version: Option<&str>, + include_optional: bool, + downloaded: &mut HashSet, + analyzed: &mut HashSet, +) -> Result<()> { + if analyzed.contains(mod_name) { + return Ok(()); + } + + if mod_name == "space-age" { + println!("Skipping reserved dependency '{mod_name}'"); + return Ok(()); + } + + let modinfo = fetch_modinfo(mod_name)?; + + let release = find_release(&modinfo, target_version) + .with_context(|| format!("Version not found for '{mod_name}'"))?; + + let version = &release.version; + println!("Processing: {mod_name} v{version}"); + analyzed.insert(mod_name.to_string()); + + let file_name = format!("{mod_name}_{version}.zip"); + let file_path = output.join(&file_name); + + if !file_path.exists() { + fs::create_dir_all(output)?; + let download_url = format!("{DOWNLOAD_BASE}/{mod_name}/{version}.zip"); + + if let Err(e) = cli_download_file(&download_url, &file_path, &file_name) { + eprintln!("Error downloading {file_name}: {e}"); + return Err(e); + } + downloaded.insert(mod_name.to_string()); + } else { + println!("Already downloaded: {file_name}"); + } + + if let Some(info_json) = &release.info_json { + let (required, optional, _incompatible) = parse_dependencies(&info_json.dependencies); + + let mut deps = required; + if include_optional { + deps.extend(optional); + } + + for dep in &deps { + if dep.name == "base" || downloaded.contains(&dep.name) || analyzed.contains(&dep.name) + { + continue; + } + println!("-> Fetching dependency: {}", dep.name); + if let Err(e) = cli_download_mod_with_deps( + &dep.name, + output, + None, + include_optional, + downloaded, + analyzed, + ) { + eprintln!("Error processing dependency {}: {}", dep.name, e); + } + } + } + + Ok(()) +} + +/// Entry point for CLI downloads. Prints progress to stdout. +#[cfg(not(feature = "gui"))] +pub fn run_cli_download( + mod_url: &str, + output: &Path, + target_version: Option<&str>, + include_optional: bool, +) -> Result<()> { + let mod_name = extract_mod_name(mod_url)?; + println!("Starting download: {mod_name}"); + println!("Output directory: {}", output.display()); + + let mut downloaded = HashSet::new(); + let mut analyzed = HashSet::new(); + + cli_download_mod_with_deps( + &mod_name, + output, + target_version, + include_optional, + &mut downloaded, + &mut analyzed, + )?; + + println!("\nAll mods downloaded successfully."); + Ok(()) +} -- cgit v1.2.3