1use anyhow::{anyhow, Result};
2use reqwest::header::HeaderValue;
3use reqwest::multipart;
4use serde_json::Value;
5use std::path::Path;
6use std::time::Instant;
7use tokio::io::AsyncReadExt as _;
8
9use super::response::{decode_json_response_body, read_error_response_text, romm_api_error};
10use super::{RommClient, SaveUploadOptions};
11
12impl RommClient {
13 pub async fn upload_rom<F>(
15 &self,
16 platform_id: u64,
17 file_path: &Path,
18 mut on_progress: F,
19 ) -> Result<()>
20 where
21 F: FnMut(u64, u64) + Send,
22 {
23 let filename = file_path
24 .file_name()
25 .and_then(|n| n.to_str())
26 .ok_or_else(|| anyhow!("Invalid filename for upload"))?;
27
28 let metadata = tokio::fs::metadata(file_path)
29 .await
30 .map_err(|e| anyhow!("Failed to read file metadata {:?}: {}", file_path, e))?;
31 let total_size = metadata.len();
32
33 let chunk_size: u64 = 2 * 1024 * 1024;
34 let total_chunks = if total_size == 0 {
35 1
36 } else {
37 total_size.div_ceil(chunk_size)
38 };
39
40 let mut start_headers = self.build_headers()?;
41 start_headers.insert(
42 reqwest::header::HeaderName::from_static("x-upload-platform"),
43 reqwest::header::HeaderValue::from_str(&platform_id.to_string())?,
44 );
45 start_headers.insert(
46 reqwest::header::HeaderName::from_static("x-upload-filename"),
47 reqwest::header::HeaderValue::from_str(filename)?,
48 );
49 start_headers.insert(
50 reqwest::header::HeaderName::from_static("x-upload-total-size"),
51 reqwest::header::HeaderValue::from_str(&total_size.to_string())?,
52 );
53 start_headers.insert(
54 reqwest::header::HeaderName::from_static("x-upload-total-chunks"),
55 reqwest::header::HeaderValue::from_str(&total_chunks.to_string())?,
56 );
57
58 let start_url = format!(
59 "{}/api/roms/upload/start",
60 self.base_url.trim_end_matches('/')
61 );
62
63 let t0 = Instant::now();
64 let resp = self
65 .http
66 .post(&start_url)
67 .headers(start_headers)
68 .send()
69 .await
70 .map_err(|e| anyhow!("upload start request error: {}", e))?;
71
72 let status = resp.status();
73 if self.verbose {
74 tracing::info!(
75 "[romm-cli] POST /api/roms/upload/start -> {} ({}ms)",
76 status.as_u16(),
77 t0.elapsed().as_millis()
78 );
79 }
80
81 if !status.is_success() {
82 let body = read_error_response_text(resp).await;
83 return Err(romm_api_error(status, &body));
84 }
85
86 let start_resp: Value = resp
87 .json()
88 .await
89 .map_err(|e| anyhow!("failed to parse start upload response: {}", e))?;
90 let upload_id = start_resp
91 .get("upload_id")
92 .and_then(|v| v.as_str())
93 .ok_or_else(|| anyhow!("Missing upload_id in start response: {}", start_resp))?
94 .to_string();
95
96 let mut file = tokio::fs::File::open(file_path).await?;
97 let mut uploaded_bytes = 0;
98 let mut buffer = vec![0u8; chunk_size as usize];
99
100 for chunk_index in 0..total_chunks {
101 let mut chunk_bytes = 0;
102 let mut chunk_data = Vec::new();
103
104 while chunk_bytes < chunk_size as usize {
105 let n = file.read(&mut buffer[..]).await?;
106 if n == 0 {
107 break;
108 }
109 chunk_data.extend_from_slice(&buffer[..n]);
110 chunk_bytes += n;
111 }
112
113 let mut chunk_headers = self.build_headers()?;
114 chunk_headers.insert(
115 reqwest::header::HeaderName::from_static("x-chunk-index"),
116 reqwest::header::HeaderValue::from_str(&chunk_index.to_string())?,
117 );
118
119 let chunk_url = format!(
120 "{}/api/roms/upload/{}",
121 self.base_url.trim_end_matches('/'),
122 upload_id
123 );
124
125 let chunk_resp = self
126 .http
127 .put(&chunk_url)
128 .headers(chunk_headers)
129 .body(chunk_data.clone())
130 .send()
131 .await
132 .map_err(|e| anyhow!("chunk upload request error: {}", e))?;
133
134 if !chunk_resp.status().is_success() {
135 let body = read_error_response_text(chunk_resp).await;
136 let cancel_url = format!(
137 "{}/api/roms/upload/{}/cancel",
138 self.base_url.trim_end_matches('/'),
139 upload_id
140 );
141 let _ = self
142 .http
143 .post(&cancel_url)
144 .headers(self.build_headers()?)
145 .send()
146 .await;
147
148 return Err(anyhow!("Failed to upload chunk {}: {}", chunk_index, body));
149 }
150
151 uploaded_bytes += chunk_data.len() as u64;
152 on_progress(uploaded_bytes, total_size);
153 }
154
155 let complete_url = format!(
156 "{}/api/roms/upload/{}/complete",
157 self.base_url.trim_end_matches('/'),
158 upload_id
159 );
160 let complete_resp = self
161 .http
162 .post(&complete_url)
163 .headers(self.build_headers()?)
164 .send()
165 .await
166 .map_err(|e| anyhow!("upload complete request error: {}", e))?;
167
168 if !complete_resp.status().is_success() {
169 let body = read_error_response_text(complete_resp).await;
170 return Err(anyhow!("Failed to complete upload: {}", body));
171 }
172
173 Ok(())
174 }
175
176 pub async fn upload_save_file(
178 &self,
179 rom_id: u64,
180 emulator: Option<&str>,
181 file_path: &Path,
182 ) -> Result<Value> {
183 let options = SaveUploadOptions {
184 emulator,
185 ..Default::default()
186 };
187 self.upload_save_file_with_options(rom_id, file_path, &options)
188 .await
189 }
190
191 pub async fn upload_save_file_with_options(
193 &self,
194 rom_id: u64,
195 file_path: &Path,
196 options: &SaveUploadOptions<'_>,
197 ) -> Result<Value> {
198 let url = format!("{}/api/saves", self.base_url.trim_end_matches('/'));
199 let bytes = tokio::fs::read(file_path)
200 .await
201 .map_err(|e| anyhow!("read {}: {e}", file_path.display()))?;
202 let fname = file_path
203 .file_name()
204 .and_then(|n| n.to_str())
205 .ok_or_else(|| anyhow!("upload path must have a unicode filename"))?;
206 let part = multipart::Part::bytes(bytes).file_name(fname.to_string());
207 let form = multipart::Form::new().part("saveFile", part);
208 let mut query: Vec<(String, String)> = vec![("rom_id".into(), rom_id.to_string())];
209 if let Some(em) = options.emulator {
210 if !em.is_empty() {
211 query.push(("emulator".into(), em.to_string()));
212 }
213 }
214 if let Some(slot) = options.slot {
215 if !slot.is_empty() {
216 query.push(("slot".into(), slot.to_string()));
217 }
218 }
219 if let Some(device_id) = options.device_id {
220 if !device_id.is_empty() {
221 query.push(("device_id".into(), device_id.to_string()));
222 }
223 }
224 if let Some(session_id) = options.session_id {
225 query.push(("session_id".into(), session_id.to_string()));
226 }
227 if options.overwrite {
228 query.push(("overwrite".into(), "true".into()));
229 }
230 let query_refs: Vec<(&str, &str)> = query
231 .iter()
232 .map(|(k, v)| (k.as_str(), v.as_str()))
233 .collect();
234 let headers = self.build_headers()?;
235 let t0 = Instant::now();
236 let resp = self
237 .http
238 .post(&url)
239 .headers(headers)
240 .query(&query_refs)
241 .multipart(form)
242 .send()
243 .await
244 .map_err(|e| anyhow!("save upload request: {e}"))?;
245 let status = resp.status();
246 if self.verbose {
247 tracing::info!(
248 "[romm-cli] POST /api/saves rom_id={rom_id} -> {} ({}ms)",
249 status.as_u16(),
250 t0.elapsed().as_millis()
251 );
252 }
253 if !status.is_success() {
254 let body = read_error_response_text(resp).await;
255 return Err(romm_api_error(status, &body));
256 }
257 let bytes = resp
258 .bytes()
259 .await
260 .map_err(|e| anyhow!("read save upload body: {e}"))?;
261 Ok(decode_json_response_body(&bytes))
262 }
263
264 pub async fn download_save_content(
266 &self,
267 save_id: u64,
268 device_id: Option<&str>,
269 session_id: Option<u64>,
270 ) -> Result<Vec<u8>> {
271 let path = format!("/api/saves/{save_id}/content");
272 let mut query = Vec::new();
273 if let Some(device_id) = device_id {
274 if !device_id.is_empty() {
275 query.push(("device_id".to_string(), device_id.to_string()));
276 }
277 }
278 if let Some(session_id) = session_id {
279 query.push(("session_id".to_string(), session_id.to_string()));
280 }
281 self.get_bytes(&path, &query).await
282 }
283
284 pub async fn upload_state_file(
286 &self,
287 rom_id: u64,
288 emulator: Option<&str>,
289 file_path: &Path,
290 ) -> Result<Value> {
291 let url = format!("{}/api/states", self.base_url.trim_end_matches('/'));
292 let bytes = tokio::fs::read(file_path)
293 .await
294 .map_err(|e| anyhow!("read {}: {e}", file_path.display()))?;
295 let fname = file_path
296 .file_name()
297 .and_then(|n| n.to_str())
298 .ok_or_else(|| anyhow!("upload path must have a unicode filename"))?;
299 let part = multipart::Part::bytes(bytes).file_name(fname.to_string());
300 let form = multipart::Form::new().part("stateFile", part);
301 let mut query: Vec<(String, String)> = vec![("rom_id".into(), rom_id.to_string())];
302 if let Some(em) = emulator {
303 if !em.is_empty() {
304 query.push(("emulator".into(), em.to_string()));
305 }
306 }
307 let query_refs: Vec<(&str, &str)> = query
308 .iter()
309 .map(|(k, v)| (k.as_str(), v.as_str()))
310 .collect();
311 let headers = self.build_headers()?;
312 let resp = self
313 .http
314 .post(&url)
315 .headers(headers)
316 .query(&query_refs)
317 .multipart(form)
318 .send()
319 .await
320 .map_err(|e| anyhow!("state upload request: {e}"))?;
321 let status = resp.status();
322 if !status.is_success() {
323 let body = read_error_response_text(resp).await;
324 return Err(romm_api_error(status, &body));
325 }
326 let bytes = resp
327 .bytes()
328 .await
329 .map_err(|e| anyhow!("read state upload body: {e}"))?;
330 Ok(decode_json_response_body(&bytes))
331 }
332
333 pub async fn upload_screenshot_file(&self, rom_id: u64, file_path: &Path) -> Result<Value> {
335 let url = format!("{}/api/screenshots", self.base_url.trim_end_matches('/'));
336 let bytes = tokio::fs::read(file_path)
337 .await
338 .map_err(|e| anyhow!("read {}: {e}", file_path.display()))?;
339 let fname = file_path
340 .file_name()
341 .and_then(|n| n.to_str())
342 .ok_or_else(|| anyhow!("upload path must have a unicode filename"))?;
343 let part = multipart::Part::bytes(bytes).file_name(fname.to_string());
344 let form = multipart::Form::new().part("screenshotFile", part);
345 let headers = self.build_headers()?;
346 let resp = self
347 .http
348 .post(&url)
349 .headers(headers)
350 .query(&[("rom_id", rom_id.to_string().as_str())])
351 .multipart(form)
352 .send()
353 .await
354 .map_err(|e| anyhow!("screenshot upload: {e}"))?;
355 let status = resp.status();
356 if !status.is_success() {
357 let body = read_error_response_text(resp).await;
358 return Err(romm_api_error(status, &body));
359 }
360 let bytes = resp
361 .bytes()
362 .await
363 .map_err(|e| anyhow!("read screenshot body: {e}"))?;
364 Ok(decode_json_response_body(&bytes))
365 }
366
367 pub async fn upload_firmware_file(&self, platform_id: u64, file_path: &Path) -> Result<Value> {
369 let url = format!("{}/api/firmware", self.base_url.trim_end_matches('/'));
370 let bytes = tokio::fs::read(file_path)
371 .await
372 .map_err(|e| anyhow!("read {}: {e}", file_path.display()))?;
373 let fname = file_path
374 .file_name()
375 .and_then(|n| n.to_str())
376 .ok_or_else(|| anyhow!("upload path must have a unicode filename"))?;
377 let part = multipart::Part::bytes(bytes).file_name(fname.to_string());
378 let form = multipart::Form::new().part("files", part);
379 let headers = self.build_headers()?;
380 let resp = self
381 .http
382 .post(&url)
383 .headers(headers)
384 .query(&[("platform_id", platform_id.to_string())])
385 .multipart(form)
386 .send()
387 .await
388 .map_err(|e| anyhow!("firmware upload: {e}"))?;
389 let status = resp.status();
390 if !status.is_success() {
391 let body = read_error_response_text(resp).await;
392 return Err(romm_api_error(status, &body));
393 }
394 let bytes = resp
395 .bytes()
396 .await
397 .map_err(|e| anyhow!("read firmware body: {e}"))?;
398 Ok(decode_json_response_body(&bytes))
399 }
400
401 pub async fn upload_rom_manual(&self, rom_id: u64, file_path: &Path) -> Result<Value> {
403 let fname = file_path
404 .file_name()
405 .and_then(|n| n.to_str())
406 .ok_or_else(|| anyhow!("manual path must have a unicode filename"))?
407 .to_string();
408 let url = format!(
409 "{}/api/roms/{}/manuals",
410 self.base_url.trim_end_matches('/'),
411 rom_id
412 );
413 let bytes = tokio::fs::read(file_path)
414 .await
415 .map_err(|e| anyhow!("read {}: {e}", file_path.display()))?;
416 let mut headers = self.build_headers()?;
417 headers.insert(
418 reqwest::header::HeaderName::from_static("x-upload-filename"),
419 HeaderValue::from_str(&fname).map_err(|_| anyhow!("invalid x-upload-filename"))?,
420 );
421 let resp = self
422 .http
423 .post(&url)
424 .headers(headers)
425 .body(bytes)
426 .send()
427 .await
428 .map_err(|e| anyhow!("manual upload: {e}"))?;
429 let status = resp.status();
430 if !status.is_success() {
431 let body = read_error_response_text(resp).await;
432 return Err(romm_api_error(status, &body));
433 }
434 let out = resp.bytes().await?;
435 Ok(decode_json_response_body(&out))
436 }
437}