Skip to main content

romm_api/client/
upload.rs

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