1use crate::config::ProxyConfig;
30use crate::download::verify_file_sha256;
31use crate::optimization::Optimizer;
32use std::error::Error;
33use std::path::{Path, PathBuf};
34
35#[derive(Debug, Clone)]
41pub struct MetalinkUrl {
42 pub url: String,
43 pub priority: u32,
46}
47
48#[derive(Debug, Clone)]
50pub struct MetalinkFile {
51 pub name: String,
52 pub size: Option<u64>,
53 pub sha256: Option<String>,
54 pub sha512: Option<String>,
55 pub md5: Option<String>,
56 pub urls: Vec<MetalinkUrl>,
58}
59
60impl MetalinkFile {
61 pub fn best_hash(&self) -> Option<(&str, &str)> {
64 if let Some(h) = &self.sha256 {
65 return Some(("sha-256", h.as_str()));
66 }
67 if let Some(h) = &self.sha512 {
68 return Some(("sha-512", h.as_str()));
69 }
70 if let Some(h) = &self.md5 {
71 return Some(("md5", h.as_str()));
72 }
73 None
74 }
75}
76
77#[derive(Debug, Clone)]
79pub struct MetalinkDoc {
80 pub files: Vec<MetalinkFile>,
81}
82
83pub fn parse(content: &str) -> Result<MetalinkDoc, Box<dyn Error + Send + Sync>> {
92 let doc = roxmltree::Document::parse(content)
93 .map_err(|e| format!("Metalink XML parse error: {}", e))?;
94
95 let root = doc.root_element();
96 let mut files: Vec<MetalinkFile> = Vec::new();
97
98 for file_node in root
99 .children()
100 .filter(|n| n.is_element() && n.tag_name().name() == "file")
101 {
102 let name = file_node
103 .attribute("name")
104 .unwrap_or("download")
105 .to_string();
106
107 let mut size: Option<u64> = None;
108 let mut sha256: Option<String> = None;
109 let mut sha512: Option<String> = None;
110 let mut md5: Option<String> = None;
111 let mut urls: Vec<MetalinkUrl> = Vec::new();
112
113 for child in file_node.children().filter(|n| n.is_element()) {
114 match child.tag_name().name() {
115 "size" => {
116 size = child
117 .text()
118 .and_then(|t| t.trim().parse::<u64>().ok());
119 }
120 "hash" => {
121 let hash_type = child.attribute("type").unwrap_or("").to_lowercase();
122 let value = child.text().map(|t| t.trim().to_ascii_lowercase());
123 match hash_type.as_str() {
124 "sha-256" => sha256 = value,
125 "sha-512" => sha512 = value,
126 "md5" => md5 = value,
127 _ => {}
128 }
129 }
130 "url" => {
131 let priority = child
132 .attribute("priority")
133 .and_then(|p| p.parse::<u32>().ok())
134 .unwrap_or(999);
135 if let Some(url_text) = child.text() {
136 let url = url_text.trim().to_string();
137 if !url.is_empty() {
138 urls.push(MetalinkUrl { url, priority });
139 }
140 }
141 }
142 _ => {}
143 }
144 }
145
146 urls.sort_by_key(|u| u.priority);
148
149 if !urls.is_empty() {
150 files.push(MetalinkFile {
151 name,
152 size,
153 sha256,
154 sha512,
155 md5,
156 urls,
157 });
158 }
159 }
160
161 if files.is_empty() {
162 return Err(
163 "Metalink contains no downloadable files with at least one URL".into(),
164 );
165 }
166
167 Ok(MetalinkDoc { files })
168}
169
170pub fn download_metalink(
184 source: &str,
185 output_dir: &str,
186 quiet: bool,
187 proxy: ProxyConfig,
188 optimizer: Optimizer,
189) -> Result<(), Box<dyn Error + Send + Sync>> {
190 let xml = fetch_manifest(source, quiet, &proxy)?;
191 let manifest = parse(&xml)?;
192
193 if !quiet {
194 println!(
195 "Metalink: {} file(s) to download",
196 manifest.files.len()
197 );
198 }
199
200 let out_dir = PathBuf::from(output_dir);
201 if !out_dir.exists() {
202 std::fs::create_dir_all(&out_dir)?;
203 }
204
205 for file in &manifest.files {
206 download_one_file(file, &out_dir, quiet, &proxy, &optimizer)?;
207 }
208
209 Ok(())
210}
211
212fn fetch_manifest(
218 source: &str,
219 quiet: bool,
220 proxy: &ProxyConfig,
221) -> Result<String, Box<dyn Error + Send + Sync>> {
222 if source.starts_with("http://") || source.starts_with("https://") {
223 if !quiet {
224 println!("Fetching Metalink manifest: {}", source);
225 }
226 let client = build_http_client(proxy)?;
227 let response = client.get(source).send()?;
228 if !response.status().is_success() {
229 return Err(format!(
230 "Failed to fetch Metalink manifest: HTTP {}",
231 response.status()
232 )
233 .into());
234 }
235 Ok(response.text()?)
236 } else {
237 std::fs::read_to_string(source)
238 .map_err(|e| format!("Cannot read Metalink file '{}': {}", source, e).into())
239 }
240}
241
242fn build_http_client(
244 proxy: &ProxyConfig,
245) -> Result<reqwest::blocking::Client, Box<dyn Error + Send + Sync>> {
246 let mut builder = reqwest::blocking::Client::builder()
247 .timeout(std::time::Duration::from_secs(60))
248 .user_agent(concat!("KGet/", env!("CARGO_PKG_VERSION")));
249
250 if proxy.enabled && let Some(proxy_url) = &proxy.url {
251 let p = match proxy.proxy_type {
252 crate::config::ProxyType::Http => reqwest::Proxy::http(proxy_url),
253 crate::config::ProxyType::Https => reqwest::Proxy::https(proxy_url),
254 crate::config::ProxyType::Socks5 => reqwest::Proxy::all(proxy_url),
255 };
256 if let Ok(mut p) = p {
257 if let (Some(u), Some(pw)) = (&proxy.username, &proxy.password) {
258 p = p.basic_auth(u, pw);
259 }
260 builder = builder.proxy(p);
261 }
262 }
263
264 Ok(builder.build()?)
265}
266
267fn download_one_file(
269 file: &MetalinkFile,
270 output_dir: &Path,
271 quiet: bool,
272 proxy: &ProxyConfig,
273 optimizer: &Optimizer,
274) -> Result<(), Box<dyn Error + Send + Sync>> {
275 let safe_name = sanitise_filename(&file.name);
276 let dest = output_dir.join(&safe_name);
277
278 if !quiet {
279 println!(
280 "\nDownloading '{}' ({} mirror(s)){}",
281 safe_name,
282 file.urls.len(),
283 file.size
284 .map(|s| format!(" [{} bytes]", s))
285 .unwrap_or_default()
286 );
287 }
288
289 let mut last_error: Option<Box<dyn Error + Send + Sync>> = None;
290
291 for (idx, mirror) in file.urls.iter().enumerate() {
292 if !quiet {
293 println!(
294 " Mirror {}/{}: {}",
295 idx + 1,
296 file.urls.len(),
297 mirror.url
298 );
299 }
300
301 match download_from_mirror(&mirror.url, &dest, quiet, proxy, optimizer) {
302 Ok(()) => {
303 if !quiet {
304 println!(" Download OK");
305 }
306
307 if let Some((hash_type, expected)) = file.best_hash() {
309 if hash_type == "sha-256" {
310 if !quiet {
311 println!(" Verifying SHA-256...");
312 }
313 match verify_file_sha256(&dest, Some(expected), None) {
314 Ok(_) => {
315 if !quiet {
316 println!(" SHA-256 OK ✓");
317 }
318 }
319 Err(e) => {
320 eprintln!(" SHA-256 mismatch: {}", e);
321 let _ = std::fs::remove_file(&dest);
323 last_error = Some(e);
324 continue;
325 }
326 }
327 } else if !quiet {
328 println!(
329 " Skipping {} verification (only sha-256 supported inline)",
330 hash_type
331 );
332 }
333 }
334
335 return Ok(());
336 }
337 Err(e) => {
338 if !quiet {
339 eprintln!(" Mirror failed: {}", e);
340 }
341 last_error = Some(e);
342 }
343 }
344 }
345
346 Err(last_error.unwrap_or_else(|| "All mirrors failed".into()))
347}
348
349fn download_from_mirror(
351 url: &str,
352 dest: &Path,
353 quiet: bool,
354 proxy: &ProxyConfig,
355 optimizer: &Optimizer,
356) -> Result<(), Box<dyn Error + Send + Sync>> {
357 use crate::advanced_download::AdvancedDownloader;
358
359 let output_path = dest
360 .to_str()
361 .ok_or("Output path contains non-UTF-8 characters")?
362 .to_string();
363
364 let dl = AdvancedDownloader::new(
365 url.to_string(),
366 output_path,
367 quiet,
368 proxy.clone(),
369 optimizer.clone(),
370 );
371 dl.download()
372}
373
374fn sanitise_filename(name: &str) -> String {
376 let cleaned: String = name
377 .chars()
378 .map(|c| match c {
379 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0' => '_',
380 c => c,
381 })
382 .collect();
383
384 let cleaned = cleaned.trim_matches('.').trim();
385 if cleaned.is_empty() {
386 "download".to_string()
387 } else {
388 cleaned.to_string()
389 }
390}
391
392pub fn is_metalink(source: &str) -> bool {
398 let lower = source.to_lowercase();
399 let base = lower.split('?').next().unwrap_or(&lower);
401 base.ends_with(".meta4") || base.ends_with(".metalink")
402}