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.text().and_then(|t| t.trim().parse::<u64>().ok());
117 }
118 "hash" => {
119 let hash_type = child.attribute("type").unwrap_or("").to_lowercase();
120 let value = child.text().map(|t| t.trim().to_ascii_lowercase());
121 match hash_type.as_str() {
122 "sha-256" => sha256 = value,
123 "sha-512" => sha512 = value,
124 "md5" => md5 = value,
125 _ => {}
126 }
127 }
128 "url" => {
129 let priority = child
130 .attribute("priority")
131 .and_then(|p| p.parse::<u32>().ok())
132 .unwrap_or(999);
133 if let Some(url_text) = child.text() {
134 let url = url_text.trim().to_string();
135 if !url.is_empty() {
136 urls.push(MetalinkUrl { url, priority });
137 }
138 }
139 }
140 _ => {}
141 }
142 }
143
144 urls.sort_by_key(|u| u.priority);
146
147 if !urls.is_empty() {
148 files.push(MetalinkFile {
149 name,
150 size,
151 sha256,
152 sha512,
153 md5,
154 urls,
155 });
156 }
157 }
158
159 if files.is_empty() {
160 return Err("Metalink contains no downloadable files with at least one URL".into());
161 }
162
163 Ok(MetalinkDoc { files })
164}
165
166pub fn download_metalink(
180 source: &str,
181 output_dir: &str,
182 quiet: bool,
183 proxy: ProxyConfig,
184 optimizer: Optimizer,
185) -> Result<(), Box<dyn Error + Send + Sync>> {
186 let xml = fetch_manifest(source, quiet, &proxy)?;
187 let manifest = parse(&xml)?;
188
189 if !quiet {
190 println!("Metalink: {} file(s) to download", manifest.files.len());
191 }
192
193 let out_dir = PathBuf::from(output_dir);
194 if !out_dir.exists() {
195 std::fs::create_dir_all(&out_dir)?;
196 }
197
198 for file in &manifest.files {
199 download_one_file(file, &out_dir, quiet, &proxy, &optimizer)?;
200 }
201
202 Ok(())
203}
204
205fn fetch_manifest(
211 source: &str,
212 quiet: bool,
213 proxy: &ProxyConfig,
214) -> Result<String, Box<dyn Error + Send + Sync>> {
215 if source.starts_with("http://") || source.starts_with("https://") {
216 if !quiet {
217 println!("Fetching Metalink manifest: {}", source);
218 }
219 let client = build_http_client(proxy)?;
220 let response = client.get(source).send()?;
221 if !response.status().is_success() {
222 return Err(format!(
223 "Failed to fetch Metalink manifest: HTTP {}",
224 response.status()
225 )
226 .into());
227 }
228 Ok(response.text()?)
229 } else {
230 std::fs::read_to_string(source)
231 .map_err(|e| format!("Cannot read Metalink file '{}': {}", source, e).into())
232 }
233}
234
235fn build_http_client(
237 proxy: &ProxyConfig,
238) -> Result<reqwest::blocking::Client, Box<dyn Error + Send + Sync>> {
239 let mut builder = reqwest::blocking::Client::builder()
240 .timeout(std::time::Duration::from_secs(60))
241 .user_agent(concat!("KGet/", env!("CARGO_PKG_VERSION")));
242
243 if proxy.enabled
244 && let Some(proxy_url) = &proxy.url
245 {
246 let p = match proxy.proxy_type {
247 crate::config::ProxyType::Http => reqwest::Proxy::http(proxy_url),
248 crate::config::ProxyType::Https => reqwest::Proxy::https(proxy_url),
249 crate::config::ProxyType::Socks5 => reqwest::Proxy::all(proxy_url),
250 };
251 if let Ok(mut p) = p {
252 if let (Some(u), Some(pw)) = (&proxy.username, &proxy.password) {
253 p = p.basic_auth(u, pw);
254 }
255 builder = builder.proxy(p);
256 }
257 }
258
259 Ok(builder.build()?)
260}
261
262fn download_one_file(
264 file: &MetalinkFile,
265 output_dir: &Path,
266 quiet: bool,
267 proxy: &ProxyConfig,
268 optimizer: &Optimizer,
269) -> Result<(), Box<dyn Error + Send + Sync>> {
270 let safe_name = sanitise_filename(&file.name);
271 let dest = output_dir.join(&safe_name);
272
273 if !quiet {
274 println!(
275 "\nDownloading '{}' ({} mirror(s)){}",
276 safe_name,
277 file.urls.len(),
278 file.size
279 .map(|s| format!(" [{} bytes]", s))
280 .unwrap_or_default()
281 );
282 }
283
284 let mut last_error: Option<Box<dyn Error + Send + Sync>> = None;
285
286 for (idx, mirror) in file.urls.iter().enumerate() {
287 if !quiet {
288 println!(" Mirror {}/{}: {}", idx + 1, file.urls.len(), mirror.url);
289 }
290
291 match download_from_mirror(&mirror.url, &dest, quiet, proxy, optimizer) {
292 Ok(()) => {
293 if !quiet {
294 println!(" Download OK");
295 }
296
297 if let Some((hash_type, expected)) = file.best_hash() {
299 if hash_type == "sha-256" {
300 if !quiet {
301 println!(" Verifying SHA-256...");
302 }
303 match verify_file_sha256(&dest, Some(expected), None) {
304 Ok(_) => {
305 if !quiet {
306 println!(" SHA-256 OK ✓");
307 }
308 }
309 Err(e) => {
310 eprintln!(" SHA-256 mismatch: {}", e);
311 let _ = std::fs::remove_file(&dest);
313 last_error = Some(e);
314 continue;
315 }
316 }
317 } else if !quiet {
318 println!(
319 " Skipping {} verification (only sha-256 supported inline)",
320 hash_type
321 );
322 }
323 }
324
325 return Ok(());
326 }
327 Err(e) => {
328 if !quiet {
329 eprintln!(" Mirror failed: {}", e);
330 }
331 last_error = Some(e);
332 }
333 }
334 }
335
336 Err(last_error.unwrap_or_else(|| "All mirrors failed".into()))
337}
338
339fn download_from_mirror(
341 url: &str,
342 dest: &Path,
343 quiet: bool,
344 proxy: &ProxyConfig,
345 optimizer: &Optimizer,
346) -> Result<(), Box<dyn Error + Send + Sync>> {
347 use crate::advanced_download::AdvancedDownloader;
348
349 let output_path = dest
350 .to_str()
351 .ok_or("Output path contains non-UTF-8 characters")?
352 .to_string();
353
354 let dl = AdvancedDownloader::new(
355 url.to_string(),
356 output_path,
357 quiet,
358 proxy.clone(),
359 optimizer.clone(),
360 )?;
361 dl.download()
362}
363
364fn sanitise_filename(name: &str) -> String {
366 let cleaned: String = name
367 .chars()
368 .map(|c| match c {
369 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0' => '_',
370 c => c,
371 })
372 .collect();
373
374 let cleaned = cleaned.trim_matches('.').trim();
375 if cleaned.is_empty() {
376 "download".to_string()
377 } else {
378 cleaned.to_string()
379 }
380}
381
382pub fn is_metalink(source: &str) -> bool {
388 let lower = source.to_lowercase();
389 let base = lower.split('?').next().unwrap_or(&lower);
391 base.ends_with(".meta4") || base.ends_with(".metalink")
392}