Skip to main content

romm_cli/client/
upload.rs

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    /// Uploads a ROM file to the server using the RomM chunked upload API.
14    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    /// Uploads a game save file to the server.
177    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    /// Uploads a game save file with sync-specific options.
192    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    /// Downloads save content from `GET /api/saves/{id}/content`.
265    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    /// `POST /api/states` with multipart field `stateFile`.
285    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    /// `POST /api/screenshots` with multipart field `screenshotFile`.
334    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    /// `POST /api/firmware?platform_id=` with multipart `files`.
368    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    /// `POST /api/roms/{id}/manuals` — raw file body with `x-upload-filename` header.
402    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}