romm_cli/client/
download.rs1use anyhow::{anyhow, Result};
2use reqwest::Url;
3use std::path::Path;
4use std::time::Instant;
5use tokio::io::AsyncWriteExt as _;
6
7use crate::config::normalize_romm_origin;
8use crate::core::interrupt::cancelled_error;
9
10use super::response::{read_error_response_text, romm_api_error};
11use super::RommClient;
12
13impl RommClient {
14 pub async fn download_rom<F>(
16 &self,
17 rom_id: u64,
18 save_path: &Path,
19 mut on_progress: F,
20 ) -> Result<()>
21 where
22 F: FnMut(u64, u64) + Send,
23 {
24 self.download_rom_with_cancel(rom_id, save_path, |_, _| false, &mut on_progress)
25 .await
26 }
27
28 pub async fn download_rom_with_cancel<F, C>(
29 &self,
30 rom_id: u64,
31 save_path: &Path,
32 is_cancelled: C,
33 on_progress: &mut F,
34 ) -> Result<()>
35 where
36 F: FnMut(u64, u64) + Send,
37 C: FnMut(u64, u64) -> bool + Send,
38 {
39 let filename = filename_hint(save_path);
40 let query = vec![
41 ("rom_ids".to_string(), rom_id.to_string()),
42 ("filename".to_string(), filename),
43 ];
44 self.download_url_with_query_with_cancel(
45 "/api/roms/download",
46 &query,
47 save_path,
48 is_cancelled,
49 on_progress,
50 )
51 .await
52 }
53
54 pub async fn download_url_with_cancel<F, C>(
56 &self,
57 url: &str,
58 save_path: &Path,
59 is_cancelled: C,
60 on_progress: &mut F,
61 ) -> Result<()>
62 where
63 F: FnMut(u64, u64) + Send,
64 C: FnMut(u64, u64) -> bool + Send,
65 {
66 self.download_url_with_query_with_cancel(url, &[], save_path, is_cancelled, on_progress)
67 .await
68 }
69
70 pub async fn download_url_with_query_with_cancel<F, C>(
72 &self,
73 url: &str,
74 query: &[(String, String)],
75 save_path: &Path,
76 mut is_cancelled: C,
77 on_progress: &mut F,
78 ) -> Result<()>
79 where
80 F: FnMut(u64, u64) + Send,
81 C: FnMut(u64, u64) -> bool + Send,
82 {
83 let url = self.resolve_download_url(url)?;
84 let filename = filename_hint(save_path);
85 let mut headers = self.build_headers()?;
86
87 let existing_len = tokio::fs::metadata(save_path)
88 .await
89 .map(|m| m.len())
90 .unwrap_or(0);
91
92 if existing_len > 0 {
93 let range = format!("bytes={existing_len}-");
94 if let Ok(v) = reqwest::header::HeaderValue::from_str(&range) {
95 headers.insert(reqwest::header::RANGE, v);
96 }
97 }
98
99 if let Some(parent) = save_path.parent() {
100 tokio::fs::create_dir_all(parent)
101 .await
102 .map_err(|e| anyhow!("create download parent dir {:?}: {e}", parent))?;
103 }
104
105 let t0 = Instant::now();
106 let mut resp = self
107 .http
108 .get(&url)
109 .headers(headers)
110 .query(query)
111 .send()
112 .await
113 .map_err(|e| anyhow!("download request error: {e}"))?;
114
115 let status = resp.status();
116 if self.verbose {
117 tracing::info!(
118 "[romm-cli] GET {} filename={:?} -> {} ({}ms)",
119 url,
120 filename,
121 status.as_u16(),
122 t0.elapsed().as_millis()
123 );
124 }
125 if !status.is_success() {
126 let body = read_error_response_text(resp).await;
127 return Err(romm_api_error(status, &body));
128 }
129
130 let (mut received, total, mut file) = if status == reqwest::StatusCode::PARTIAL_CONTENT {
131 let remaining = resp.content_length().unwrap_or(0);
132 let total = existing_len + remaining;
133 let file = tokio::fs::OpenOptions::new()
134 .append(true)
135 .open(save_path)
136 .await
137 .map_err(|e| anyhow!("open file for append {:?}: {e}", save_path))?;
138 (existing_len, total, file)
139 } else {
140 let total = resp.content_length().unwrap_or(0);
141 let file = tokio::fs::File::create(save_path)
142 .await
143 .map_err(|e| anyhow!("create file {:?}: {e}", save_path))?;
144 (0u64, total, file)
145 };
146
147 if is_cancelled(received, total) {
148 return Err(cancelled_error());
149 }
150
151 while let Some(chunk) = resp.chunk().await.map_err(|e| anyhow!("read chunk: {e}"))? {
152 if is_cancelled(received, total) {
153 return Err(cancelled_error());
154 }
155 file.write_all(&chunk)
156 .await
157 .map_err(|e| anyhow!("write chunk {:?}: {e}", save_path))?;
158 received += chunk.len() as u64;
159 on_progress(received, total);
160 }
161
162 Ok(())
163 }
164
165 fn resolve_download_url(&self, url: &str) -> Result<String> {
166 let trimmed = url.trim();
167 if trimmed.is_empty() {
168 return Err(anyhow!("download URL cannot be empty"));
169 }
170 if let Ok(parsed) = Url::parse(trimmed) {
171 return Ok(parsed.to_string());
172 }
173
174 let base = Url::parse(&normalize_romm_origin(&self.base_url))
175 .map_err(|e| anyhow!("invalid RomM base URL: {e}"))?;
176 let joined = base
177 .join(trimmed)
178 .map_err(|e| anyhow!("could not resolve download URL {trimmed:?}: {e}"))?;
179 Ok(joined.to_string())
180 }
181}
182
183fn filename_hint(save_path: &Path) -> String {
184 save_path
185 .file_name()
186 .and_then(|n| n.to_str())
187 .unwrap_or("download.bin")
188 .to_string()
189}