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 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 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 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 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 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 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 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 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}