Skip to main content

romm_cli/
client.rs

1//! HTTP client wrapper around the ROMM API.
2//!
3//! `RommClient` owns a configured `reqwest::Client` plus base URL and
4//! authentication settings. Frontends (CLI, TUI, or a future GUI) depend
5//! on this type instead of talking to `reqwest` directly.
6
7use anyhow::{anyhow, Result};
8use base64::{engine::general_purpose, Engine as _};
9use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
10use reqwest::multipart;
11use reqwest::{Client as HttpClient, Method, Url};
12use serde_json::Value;
13use std::path::Path;
14use std::time::Instant;
15use tokio::io::AsyncWriteExt as _;
16
17use crate::config::{normalize_romm_origin, AuthConfig, Config};
18use crate::core::interrupt::cancelled_error;
19use crate::endpoints::Endpoint;
20
21/// Optional query fields for save uploads used in sync flows.
22#[derive(Debug, Clone, Default)]
23pub struct SaveUploadOptions<'a> {
24    pub emulator: Option<&'a str>,
25    pub slot: Option<&'a str>,
26    pub device_id: Option<&'a str>,
27    pub session_id: Option<u64>,
28    pub overwrite: bool,
29}
30
31/// Default `User-Agent` for every request. The stock `reqwest` UA is sometimes blocked at the HTTP
32/// layer (403, etc.) by reverse proxies; override with env `ROMM_USER_AGENT` if needed.
33fn http_user_agent() -> String {
34    match std::env::var("ROMM_USER_AGENT") {
35        Ok(s) if !s.trim().is_empty() => s,
36        _ => format!(
37            "Mozilla/5.0 (compatible; romm-cli/{}; +https://github.com/patricksmill/romm-cli)",
38            env!("CARGO_PKG_VERSION")
39        ),
40    }
41}
42
43/// Map a successful HTTP response body to JSON [`Value`].
44///
45/// Empty or whitespace-only bodies become [`Value::Null`] (e.g. HTTP 204).
46/// Non-JSON UTF-8 bodies are wrapped as `{"_non_json_body": "..."}`.
47fn decode_json_response_body(bytes: &[u8]) -> Value {
48    if bytes.is_empty() || bytes.iter().all(|b| b.is_ascii_whitespace()) {
49        return Value::Null;
50    }
51    serde_json::from_slice(bytes).unwrap_or_else(|_| {
52        serde_json::json!({
53            "_non_json_body": String::from_utf8_lossy(bytes).to_string()
54        })
55    })
56}
57
58fn version_from_heartbeat_json(v: &Value) -> Option<String> {
59    v.get("SYSTEM")?.get("VERSION")?.as_str().map(String::from)
60}
61
62/// High-level HTTP client for the ROMM API.
63///
64/// This type hides the details of `reqwest` and authentication headers
65/// behind a small interface that all frontends can share.
66///
67/// # Examples
68///
69/// ```no_run
70/// # use romm_cli::config::Config;
71/// # use romm_cli::client::RommClient;
72/// # async fn example() -> anyhow::Result<()> {
73/// let config = Config {
74///     base_url: "https://romm.example.com".to_string(),
75///     download_dir: "./downloads".to_string(),
76///     use_https: true,
77///     auth: None,
78///     extras_defaults: Default::default(),
79///     save_sync: Default::default(),
80/// };
81/// let client = RommClient::new(&config, false)?;
82/// # Ok(())
83/// # }
84/// ```
85#[derive(Clone)]
86pub struct RommClient {
87    /// The underlying HTTP client.
88    http: HttpClient,
89    /// The base URL of the RomM server.
90    base_url: String,
91    /// Current authentication configuration.
92    auth: Option<AuthConfig>,
93    /// Whether to log request details to stderr.
94    verbose: bool,
95}
96
97/// Returns the browser-style origin for RomM (no `/api` suffix).
98///
99/// Same as [`crate::config::normalize_romm_origin`].
100pub fn api_root_url(base_url: &str) -> String {
101    normalize_romm_origin(base_url)
102}
103
104fn alternate_http_scheme_root(root: &str) -> Option<String> {
105    root.strip_prefix("http://")
106        .map(|rest| format!("https://{}", rest))
107        .or_else(|| {
108            root.strip_prefix("https://")
109                .map(|rest| format!("http://{}", rest))
110        })
111}
112
113/// Resolves the origin used to fetch `/openapi.json`.
114///
115/// Normally equals [`normalize_romm_origin`] applied to `api_base_url`,
116/// but can be overridden by the `ROMM_OPENAPI_BASE_URL` environment variable.
117pub fn resolve_openapi_root(api_base_url: &str) -> String {
118    if let Ok(s) = std::env::var("ROMM_OPENAPI_BASE_URL") {
119        let t = s.trim();
120        if !t.is_empty() {
121            return normalize_romm_origin(t);
122        }
123    }
124    normalize_romm_origin(api_base_url)
125}
126
127/// Returns a list of candidate URLs to try for the OpenAPI JSON document.
128///
129/// This includes both HTTP/HTTPS schemes and common paths like `/openapi.json`
130/// and `/api/openapi.json`.
131pub fn openapi_spec_urls(api_root: &str) -> Vec<String> {
132    let root = api_root.trim_end_matches('/').to_string();
133    let mut roots = vec![root.clone()];
134    if let Some(alt) = alternate_http_scheme_root(&root) {
135        if alt != root {
136            roots.push(alt);
137        }
138    }
139
140    let mut urls = Vec::new();
141    for r in roots {
142        let b = r.trim_end_matches('/');
143        urls.push(format!("{b}/openapi.json"));
144        urls.push(format!("{b}/api/openapi.json"));
145    }
146    urls
147}
148
149impl RommClient {
150    /// Construct a new client from the high-level [`Config`].
151    ///
152    /// `verbose` enables stderr request logging (method, path, query key names, status, timing).
153    /// This is typically done once in `main` and the resulting `RommClient` is shared
154    /// (by reference or cloning) with the chosen frontend.
155    pub fn new(config: &Config, verbose: bool) -> Result<Self> {
156        let http = HttpClient::builder()
157            .user_agent(http_user_agent())
158            .build()?;
159        Ok(Self {
160            http,
161            base_url: config.base_url.clone(),
162            auth: config.auth.clone(),
163            verbose,
164        })
165    }
166
167    /// Returns true if verbose logging is enabled.
168    pub fn verbose(&self) -> bool {
169        self.verbose
170    }
171
172    /// Build the HTTP headers for the current authentication mode.
173    ///
174    /// This helper centralises all auth logic so that the rest of the
175    /// code never needs to worry about `Basic` vs `Bearer` vs API key.
176    fn build_headers(&self) -> Result<HeaderMap> {
177        let mut headers = HeaderMap::new();
178
179        if let Some(auth) = &self.auth {
180            match auth {
181                AuthConfig::Basic { username, password } => {
182                    let creds = format!("{username}:{password}");
183                    let encoded = general_purpose::STANDARD.encode(creds.as_bytes());
184                    let value = format!("Basic {encoded}");
185                    headers.insert(
186                        AUTHORIZATION,
187                        HeaderValue::from_str(&value)
188                            .map_err(|_| anyhow!("invalid basic auth header value"))?,
189                    );
190                }
191                AuthConfig::Bearer { token } => {
192                    let value = format!("Bearer {token}");
193                    headers.insert(
194                        AUTHORIZATION,
195                        HeaderValue::from_str(&value)
196                            .map_err(|_| anyhow!("invalid bearer auth header value"))?,
197                    );
198                }
199                AuthConfig::ApiKey { header, key } => {
200                    let name = reqwest::header::HeaderName::from_bytes(header.as_bytes()).map_err(
201                        |_| anyhow!("invalid API_KEY_HEADER, must be a valid HTTP header name"),
202                    )?;
203                    headers.insert(
204                        name,
205                        HeaderValue::from_str(key)
206                            .map_err(|_| anyhow!("invalid API_KEY header value"))?,
207                    );
208                }
209            }
210        }
211
212        Ok(headers)
213    }
214
215    /// Executes a typed [`Endpoint`] and returns its deserialized output.
216    ///
217    /// This is the preferred way to interact with the API when a typed
218    /// endpoint definition exists.
219    pub async fn call<E>(&self, ep: &E) -> anyhow::Result<E::Output>
220    where
221        E: Endpoint,
222        E::Output: serde::de::DeserializeOwned,
223    {
224        let method = ep.method();
225        let path = ep.path();
226        let query = ep.query();
227        let body = ep.body();
228
229        let value = self.request_json(method, &path, &query, body).await?;
230        let output = serde_json::from_value(value)
231            .map_err(|e| anyhow!("failed to decode response for {} {}: {}", method, path, e))?;
232
233        Ok(output)
234    }
235
236    /// Low-level helper that issues an HTTP request and returns a raw JSON [`Value`].
237    ///
238    /// Higher-level code should generally prefer [`RommClient::call`].
239    pub async fn request_json(
240        &self,
241        method: &str,
242        path: &str,
243        query: &[(String, String)],
244        body: Option<Value>,
245    ) -> Result<Value> {
246        let url = format!(
247            "{}/{}",
248            self.base_url.trim_end_matches('/'),
249            path.trim_start_matches('/')
250        );
251        let headers = self.build_headers()?;
252
253        let http_method = Method::from_bytes(method.as_bytes())
254            .map_err(|_| anyhow!("invalid HTTP method: {method}"))?;
255
256        // Ensure query params serialize as key=value pairs (reqwest/serde_urlencoded
257        // expect sequences of (key, value); using &[(&str, &str)] guarantees correct encoding).
258        let query_refs: Vec<(&str, &str)> = query
259            .iter()
260            .map(|(k, v)| (k.as_str(), v.as_str()))
261            .collect();
262
263        let mut req = self
264            .http
265            .request(http_method, &url)
266            .headers(headers)
267            .query(&query_refs);
268
269        if let Some(body) = body {
270            req = req.json(&body);
271        }
272
273        let t0 = Instant::now();
274        let resp = req
275            .send()
276            .await
277            .map_err(|e| anyhow!("request error: {e}"))?;
278
279        let status = resp.status();
280        if self.verbose {
281            let keys: Vec<&str> = query.iter().map(|(k, _)| k.as_str()).collect();
282            tracing::info!(
283                "[romm-cli] {} {} query_keys={:?} -> {} ({}ms)",
284                method,
285                path,
286                keys,
287                status.as_u16(),
288                t0.elapsed().as_millis()
289            );
290        }
291        if !status.is_success() {
292            let body = resp.text().await.unwrap_or_default();
293            return Err(anyhow!(
294                "ROMM API error: {} {} - {}",
295                status.as_u16(),
296                status.canonical_reason().unwrap_or(""),
297                body
298            ));
299        }
300
301        let bytes = resp
302            .bytes()
303            .await
304            .map_err(|e| anyhow!("read response body: {e}"))?;
305
306        Ok(decode_json_response_body(&bytes))
307    }
308
309    pub async fn request_json_unauthenticated(
310        &self,
311        method: &str,
312        path: &str,
313        query: &[(String, String)],
314        body: Option<Value>,
315    ) -> Result<Value> {
316        let url = format!(
317            "{}/{}",
318            self.base_url.trim_end_matches('/'),
319            path.trim_start_matches('/')
320        );
321        let headers = HeaderMap::new();
322
323        let http_method = Method::from_bytes(method.as_bytes())
324            .map_err(|_| anyhow!("invalid HTTP method: {method}"))?;
325
326        // Ensure query params serialize as key=value pairs (reqwest/serde_urlencoded
327        // expect sequences of (key, value); using &[(&str, &str)] guarantees correct encoding).
328        let query_refs: Vec<(&str, &str)> = query
329            .iter()
330            .map(|(k, v)| (k.as_str(), v.as_str()))
331            .collect();
332
333        let mut req = self
334            .http
335            .request(http_method, &url)
336            .headers(headers)
337            .query(&query_refs);
338
339        if let Some(body) = body {
340            req = req.json(&body);
341        }
342
343        let t0 = Instant::now();
344        let resp = req
345            .send()
346            .await
347            .map_err(|e| anyhow!("request error: {e}"))?;
348
349        let status = resp.status();
350        if self.verbose {
351            let keys: Vec<&str> = query.iter().map(|(k, _)| k.as_str()).collect();
352            tracing::info!(
353                "[romm-cli] {} {} query_keys={:?} -> {} ({}ms)",
354                method,
355                path,
356                keys,
357                status.as_u16(),
358                t0.elapsed().as_millis()
359            );
360        }
361        if !status.is_success() {
362            let body = resp.text().await.unwrap_or_default();
363            return Err(anyhow!(
364                "ROMM API error: {} {} - {}",
365                status.as_u16(),
366                status.canonical_reason().unwrap_or(""),
367                body
368            ));
369        }
370
371        let bytes = resp
372            .bytes()
373            .await
374            .map_err(|e| anyhow!("read response body: {e}"))?;
375
376        Ok(decode_json_response_body(&bytes))
377    }
378
379    /// RomM application version from `GET /api/heartbeat` (`SYSTEM.VERSION`), if the endpoint succeeds.
380    pub async fn rom_server_version_from_heartbeat(&self) -> Option<String> {
381        let v = self
382            .request_json_unauthenticated("GET", "/api/heartbeat", &[], None)
383            .await
384            .ok()?;
385        version_from_heartbeat_json(&v)
386    }
387
388    /// GET the OpenAPI spec from the server. Tries [`openapi_spec_urls`] in order (HTTP/HTTPS and
389    /// `/openapi.json` vs `/api/openapi.json`). Uses [`resolve_openapi_root`] for the origin.
390    pub async fn fetch_openapi_json(&self) -> Result<String> {
391        let root = resolve_openapi_root(&self.base_url);
392        let urls = openapi_spec_urls(&root);
393        let mut failures = Vec::new();
394        for url in &urls {
395            match self.fetch_openapi_json_once(url).await {
396                Ok(body) => return Ok(body),
397                Err(e) => failures.push(format!("{url}: {e:#}")),
398            }
399        }
400        Err(anyhow!(
401            "could not download OpenAPI ({} attempt(s)): {}",
402            failures.len(),
403            failures.join(" | ")
404        ))
405    }
406
407    async fn fetch_openapi_json_once(&self, url: &str) -> Result<String> {
408        let headers = self.build_headers()?;
409
410        let t0 = Instant::now();
411        let resp = self
412            .http
413            .get(url)
414            .headers(headers)
415            .send()
416            .await
417            .map_err(|e| anyhow!("request failed: {e}"))?;
418
419        let status = resp.status();
420        if self.verbose {
421            tracing::info!(
422                "[romm-cli] GET {} -> {} ({}ms)",
423                url,
424                status.as_u16(),
425                t0.elapsed().as_millis()
426            );
427        }
428        if !status.is_success() {
429            let body = resp.text().await.unwrap_or_default();
430            return Err(anyhow!(
431                "HTTP {} {} - {}",
432                status.as_u16(),
433                status.canonical_reason().unwrap_or(""),
434                body.chars().take(500).collect::<String>()
435            ));
436        }
437
438        resp.text()
439            .await
440            .map_err(|e| anyhow!("read OpenAPI body: {e}"))
441    }
442
443    /// Downloads a ROM (or multiple ROMs as a zip) to the specified path.
444    ///
445    /// This method supports resuming interrupted downloads by checking if the file
446    /// already exists and sending an HTTP `Range` header.
447    ///
448    /// # Progress
449    ///
450    /// The `on_progress` callback is called with `(received_bytes, total_bytes)`.
451    pub async fn download_rom<F>(
452        &self,
453        rom_id: u64,
454        save_path: &Path,
455        mut on_progress: F,
456    ) -> Result<()>
457    where
458        F: FnMut(u64, u64) + Send,
459    {
460        self.download_rom_with_cancel(rom_id, save_path, |_, _| false, &mut on_progress)
461            .await
462    }
463
464    pub async fn download_rom_with_cancel<F, C>(
465        &self,
466        rom_id: u64,
467        save_path: &Path,
468        is_cancelled: C,
469        on_progress: &mut F,
470    ) -> Result<()>
471    where
472        F: FnMut(u64, u64) + Send,
473        C: FnMut(u64, u64) -> bool + Send,
474    {
475        let filename = filename_hint(save_path);
476        let query = vec![
477            ("rom_ids".to_string(), rom_id.to_string()),
478            ("filename".to_string(), filename),
479        ];
480        self.download_url_with_query_with_cancel(
481            "/api/roms/download",
482            &query,
483            save_path,
484            is_cancelled,
485            on_progress,
486        )
487        .await
488    }
489
490    /// Downloads an arbitrary URL to `save_path`, supporting auth headers and resume.
491    pub async fn download_url_with_cancel<F, C>(
492        &self,
493        url: &str,
494        save_path: &Path,
495        is_cancelled: C,
496        on_progress: &mut F,
497    ) -> Result<()>
498    where
499        F: FnMut(u64, u64) + Send,
500        C: FnMut(u64, u64) -> bool + Send,
501    {
502        self.download_url_with_query_with_cancel(url, &[], save_path, is_cancelled, on_progress)
503            .await
504    }
505
506    /// Downloads an arbitrary URL and query to `save_path`, supporting auth headers and resume.
507    pub async fn download_url_with_query_with_cancel<F, C>(
508        &self,
509        url: &str,
510        query: &[(String, String)],
511        save_path: &Path,
512        mut is_cancelled: C,
513        on_progress: &mut F,
514    ) -> Result<()>
515    where
516        F: FnMut(u64, u64) + Send,
517        C: FnMut(u64, u64) -> bool + Send,
518    {
519        let url = self.resolve_download_url(url)?;
520        let filename = filename_hint(save_path);
521        let mut headers = self.build_headers()?;
522
523        // Check for an existing partial file to resume from.
524        let existing_len = tokio::fs::metadata(save_path)
525            .await
526            .map(|m| m.len())
527            .unwrap_or(0);
528
529        if existing_len > 0 {
530            let range = format!("bytes={existing_len}-");
531            if let Ok(v) = reqwest::header::HeaderValue::from_str(&range) {
532                headers.insert(reqwest::header::RANGE, v);
533            }
534        }
535
536        if let Some(parent) = save_path.parent() {
537            tokio::fs::create_dir_all(parent)
538                .await
539                .map_err(|e| anyhow!("create download parent dir {:?}: {e}", parent))?;
540        }
541
542        let t0 = Instant::now();
543        let mut resp = self
544            .http
545            .get(&url)
546            .headers(headers)
547            .query(query)
548            .send()
549            .await
550            .map_err(|e| anyhow!("download request error: {e}"))?;
551
552        let status = resp.status();
553        if self.verbose {
554            tracing::info!(
555                "[romm-cli] GET {} filename={:?} -> {} ({}ms)",
556                url,
557                filename,
558                status.as_u16(),
559                t0.elapsed().as_millis()
560            );
561        }
562        if !status.is_success() {
563            let body = resp.text().await.unwrap_or_default();
564            return Err(anyhow!(
565                "ROMM API error: {} {} - {}",
566                status.as_u16(),
567                status.canonical_reason().unwrap_or(""),
568                body
569            ));
570        }
571
572        // Determine whether the server honoured our Range header.
573        let (mut received, total, mut file) = if status == reqwest::StatusCode::PARTIAL_CONTENT {
574            // 206 — resume: content_length is the *remaining* bytes.
575            let remaining = resp.content_length().unwrap_or(0);
576            let total = existing_len + remaining;
577            let file = tokio::fs::OpenOptions::new()
578                .append(true)
579                .open(save_path)
580                .await
581                .map_err(|e| anyhow!("open file for append {:?}: {e}", save_path))?;
582            (existing_len, total, file)
583        } else {
584            // 200 — server doesn't support ranges; start from scratch.
585            let total = resp.content_length().unwrap_or(0);
586            let file = tokio::fs::File::create(save_path)
587                .await
588                .map_err(|e| anyhow!("create file {:?}: {e}", save_path))?;
589            (0u64, total, file)
590        };
591
592        if is_cancelled(received, total) {
593            return Err(cancelled_error());
594        }
595
596        while let Some(chunk) = resp.chunk().await.map_err(|e| anyhow!("read chunk: {e}"))? {
597            if is_cancelled(received, total) {
598                return Err(cancelled_error());
599            }
600            file.write_all(&chunk)
601                .await
602                .map_err(|e| anyhow!("write chunk {:?}: {e}", save_path))?;
603            received += chunk.len() as u64;
604            on_progress(received, total);
605        }
606
607        Ok(())
608    }
609
610    fn resolve_download_url(&self, url: &str) -> Result<String> {
611        let trimmed = url.trim();
612        if trimmed.is_empty() {
613            return Err(anyhow!("download URL cannot be empty"));
614        }
615        if let Ok(parsed) = Url::parse(trimmed) {
616            return Ok(parsed.to_string());
617        }
618
619        let base = Url::parse(&normalize_romm_origin(&self.base_url))
620            .map_err(|e| anyhow!("invalid RomM base URL: {e}"))?;
621        let joined = base
622            .join(trimmed)
623            .map_err(|e| anyhow!("could not resolve download URL {trimmed:?}: {e}"))?;
624        Ok(joined.to_string())
625    }
626
627    /// Uploads a ROM file to the server using the RomM chunked upload API.
628    ///
629    /// This method splits the file into chunks (default 2MB) and uploads them
630    /// sequentially, providing progress updates via the `on_progress` callback.
631    pub async fn upload_rom<F>(
632        &self,
633        platform_id: u64,
634        file_path: &Path,
635        mut on_progress: F,
636    ) -> Result<()>
637    where
638        F: FnMut(u64, u64) + Send,
639    {
640        let filename = file_path
641            .file_name()
642            .and_then(|n| n.to_str())
643            .ok_or_else(|| anyhow!("Invalid filename for upload"))?;
644
645        let metadata = tokio::fs::metadata(file_path)
646            .await
647            .map_err(|e| anyhow!("Failed to read file metadata {:?}: {}", file_path, e))?;
648        let total_size = metadata.len();
649
650        // 2MB chunk size
651        let chunk_size: u64 = 2 * 1024 * 1024;
652        // Use integer division ceiling
653        let total_chunks = if total_size == 0 {
654            1
655        } else {
656            total_size.div_ceil(chunk_size)
657        };
658
659        let mut start_headers = self.build_headers()?;
660        start_headers.insert(
661            reqwest::header::HeaderName::from_static("x-upload-platform"),
662            reqwest::header::HeaderValue::from_str(&platform_id.to_string())?,
663        );
664        start_headers.insert(
665            reqwest::header::HeaderName::from_static("x-upload-filename"),
666            reqwest::header::HeaderValue::from_str(filename)?,
667        );
668        start_headers.insert(
669            reqwest::header::HeaderName::from_static("x-upload-total-size"),
670            reqwest::header::HeaderValue::from_str(&total_size.to_string())?,
671        );
672        start_headers.insert(
673            reqwest::header::HeaderName::from_static("x-upload-total-chunks"),
674            reqwest::header::HeaderValue::from_str(&total_chunks.to_string())?,
675        );
676
677        let start_url = format!(
678            "{}/api/roms/upload/start",
679            self.base_url.trim_end_matches('/')
680        );
681
682        let t0 = Instant::now();
683        let resp = self
684            .http
685            .post(&start_url)
686            .headers(start_headers)
687            .send()
688            .await
689            .map_err(|e| anyhow!("upload start request error: {}", e))?;
690
691        let status = resp.status();
692        if self.verbose {
693            tracing::info!(
694                "[romm-cli] POST /api/roms/upload/start -> {} ({}ms)",
695                status.as_u16(),
696                t0.elapsed().as_millis()
697            );
698        }
699
700        if !status.is_success() {
701            let body = resp.text().await.unwrap_or_default();
702            return Err(anyhow!(
703                "ROMM API error: {} {} - {}",
704                status.as_u16(),
705                status.canonical_reason().unwrap_or(""),
706                body
707            ));
708        }
709
710        let start_resp: Value = resp
711            .json()
712            .await
713            .map_err(|e| anyhow!("failed to parse start upload response: {}", e))?;
714        let upload_id = start_resp
715            .get("upload_id")
716            .and_then(|v| v.as_str())
717            .ok_or_else(|| anyhow!("Missing upload_id in start response: {}", start_resp))?
718            .to_string();
719
720        use tokio::io::AsyncReadExt;
721        let mut file = tokio::fs::File::open(file_path).await?;
722        let mut uploaded_bytes = 0;
723        let mut buffer = vec![0u8; chunk_size as usize];
724
725        for chunk_index in 0..total_chunks {
726            let mut chunk_bytes = 0;
727            let mut chunk_data = Vec::new();
728
729            while chunk_bytes < chunk_size as usize {
730                let n = file.read(&mut buffer[..]).await?;
731                if n == 0 {
732                    break;
733                }
734                chunk_data.extend_from_slice(&buffer[..n]);
735                chunk_bytes += n;
736            }
737
738            let mut chunk_headers = self.build_headers()?;
739            chunk_headers.insert(
740                reqwest::header::HeaderName::from_static("x-chunk-index"),
741                reqwest::header::HeaderValue::from_str(&chunk_index.to_string())?,
742            );
743
744            let chunk_url = format!(
745                "{}/api/roms/upload/{}",
746                self.base_url.trim_end_matches('/'),
747                upload_id
748            );
749
750            let _t_chunk = Instant::now();
751            let chunk_resp = self
752                .http
753                .put(&chunk_url)
754                .headers(chunk_headers)
755                .body(chunk_data.clone())
756                .send()
757                .await
758                .map_err(|e| anyhow!("chunk upload request error: {}", e))?;
759
760            if !chunk_resp.status().is_success() {
761                let body = chunk_resp.text().await.unwrap_or_default();
762                // Attempt to cancel
763                let cancel_url = format!(
764                    "{}/api/roms/upload/{}/cancel",
765                    self.base_url.trim_end_matches('/'),
766                    upload_id
767                );
768                let _ = self
769                    .http
770                    .post(&cancel_url)
771                    .headers(self.build_headers()?)
772                    .send()
773                    .await;
774
775                return Err(anyhow!("Failed to upload chunk {}: {}", chunk_index, body));
776            }
777
778            uploaded_bytes += chunk_data.len() as u64;
779            on_progress(uploaded_bytes, total_size);
780        }
781
782        let complete_url = format!(
783            "{}/api/roms/upload/{}/complete",
784            self.base_url.trim_end_matches('/'),
785            upload_id
786        );
787        let complete_resp = self
788            .http
789            .post(&complete_url)
790            .headers(self.build_headers()?)
791            .send()
792            .await
793            .map_err(|e| anyhow!("upload complete request error: {}", e))?;
794
795        if !complete_resp.status().is_success() {
796            let body = complete_resp.text().await.unwrap_or_default();
797            return Err(anyhow!("Failed to complete upload: {}", body));
798        }
799
800        Ok(())
801    }
802
803    /// Triggers a server-side task by name (e.g., `"scan_library"`).
804    ///
805    /// # Arguments
806    ///
807    /// * `task_name` - The internal name of the task to run.
808    /// * `kwargs` - Optional JSON arguments to pass to the task.
809    pub async fn run_task(&self, task_name: &str, kwargs: Option<Value>) -> Result<Value> {
810        let path = format!("/api/tasks/run/{}", task_name);
811        self.request_json("POST", &path, &[], kwargs).await
812    }
813
814    /// Polls the status of a running task by its ID.
815    pub async fn get_task_status(&self, task_id: &str) -> Result<Value> {
816        let path = format!("/api/tasks/{}", task_id);
817        self.request_json("GET", &path, &[], None).await
818    }
819
820    /// Enqueues all runnable tasks on the server.
821    pub async fn run_all_tasks(&self) -> Result<Value> {
822        self.request_json("POST", "/api/tasks/run", &[], None).await
823    }
824
825    /// Lists all recent and active tasks.
826    pub async fn list_tasks(&self) -> Result<Value> {
827        self.request_json("GET", "/api/tasks", &[], None).await
828    }
829
830    /// Returns the current status of the task queue (active, queued, completed).
831    pub async fn get_tasks_queue_status(&self) -> Result<Value> {
832        self.request_json("GET", "/api/tasks/status", &[], None)
833            .await
834    }
835
836    /// Uploads a game save file to the server.
837    ///
838    /// # Arguments
839    ///
840    /// * `rom_id` - ID of the ROM this save belongs to.
841    /// * `emulator` - Optional name of the emulator that generated the save.
842    /// * `file_path` - Local path to the save file.
843    pub async fn upload_save_file(
844        &self,
845        rom_id: u64,
846        emulator: Option<&str>,
847        file_path: &Path,
848    ) -> Result<Value> {
849        let options = SaveUploadOptions {
850            emulator,
851            ..Default::default()
852        };
853        self.upload_save_file_with_options(rom_id, file_path, &options)
854            .await
855    }
856
857    /// Uploads a game save file with sync-specific options.
858    pub async fn upload_save_file_with_options(
859        &self,
860        rom_id: u64,
861        file_path: &Path,
862        options: &SaveUploadOptions<'_>,
863    ) -> Result<Value> {
864        let url = format!("{}/api/saves", self.base_url.trim_end_matches('/'));
865        let bytes = tokio::fs::read(file_path)
866            .await
867            .map_err(|e| anyhow!("read {}: {e}", file_path.display()))?;
868        let fname = file_path
869            .file_name()
870            .and_then(|n| n.to_str())
871            .ok_or_else(|| anyhow!("upload path must have a unicode filename"))?;
872        let part = multipart::Part::bytes(bytes).file_name(fname.to_string());
873        let form = multipart::Form::new().part("saveFile", part);
874        let mut query: Vec<(String, String)> = vec![("rom_id".into(), rom_id.to_string())];
875        if let Some(em) = options.emulator {
876            if !em.is_empty() {
877                query.push(("emulator".into(), em.to_string()));
878            }
879        }
880        if let Some(slot) = options.slot {
881            if !slot.is_empty() {
882                query.push(("slot".into(), slot.to_string()));
883            }
884        }
885        if let Some(device_id) = options.device_id {
886            if !device_id.is_empty() {
887                query.push(("device_id".into(), device_id.to_string()));
888            }
889        }
890        if let Some(session_id) = options.session_id {
891            query.push(("session_id".into(), session_id.to_string()));
892        }
893        if options.overwrite {
894            query.push(("overwrite".into(), "true".into()));
895        }
896        let query_refs: Vec<(&str, &str)> = query
897            .iter()
898            .map(|(k, v)| (k.as_str(), v.as_str()))
899            .collect();
900        let headers = self.build_headers()?;
901        let t0 = Instant::now();
902        let resp = self
903            .http
904            .post(&url)
905            .headers(headers)
906            .query(&query_refs)
907            .multipart(form)
908            .send()
909            .await
910            .map_err(|e| anyhow!("save upload request: {e}"))?;
911        let status = resp.status();
912        if self.verbose {
913            tracing::info!(
914                "[romm-cli] POST /api/saves rom_id={rom_id} -> {} ({}ms)",
915                status.as_u16(),
916                t0.elapsed().as_millis()
917            );
918        }
919        if !status.is_success() {
920            let body = resp.text().await.unwrap_or_default();
921            return Err(anyhow!(
922                "ROMM API error: {} {} - {}",
923                status.as_u16(),
924                status.canonical_reason().unwrap_or(""),
925                body
926            ));
927        }
928        let bytes = resp
929            .bytes()
930            .await
931            .map_err(|e| anyhow!("read save upload body: {e}"))?;
932        Ok(decode_json_response_body(&bytes))
933    }
934
935    /// Downloads save content from `GET /api/saves/{id}/content`.
936    pub async fn download_save_content(
937        &self,
938        save_id: u64,
939        device_id: Option<&str>,
940        session_id: Option<u64>,
941    ) -> Result<Vec<u8>> {
942        let path = format!("/api/saves/{save_id}/content");
943        let mut query = Vec::new();
944        if let Some(device_id) = device_id {
945            if !device_id.is_empty() {
946                query.push(("device_id".to_string(), device_id.to_string()));
947            }
948        }
949        if let Some(session_id) = session_id {
950            query.push(("session_id".to_string(), session_id.to_string()));
951        }
952        self.get_bytes(&path, &query).await
953    }
954
955    /// `POST /api/states` with multipart field `stateFile`.
956    pub async fn upload_state_file(
957        &self,
958        rom_id: u64,
959        emulator: Option<&str>,
960        file_path: &Path,
961    ) -> Result<Value> {
962        let url = format!("{}/api/states", self.base_url.trim_end_matches('/'));
963        let bytes = tokio::fs::read(file_path)
964            .await
965            .map_err(|e| anyhow!("read {}: {e}", file_path.display()))?;
966        let fname = file_path
967            .file_name()
968            .and_then(|n| n.to_str())
969            .ok_or_else(|| anyhow!("upload path must have a unicode filename"))?;
970        let part = multipart::Part::bytes(bytes).file_name(fname.to_string());
971        let form = multipart::Form::new().part("stateFile", part);
972        let mut query: Vec<(String, String)> = vec![("rom_id".into(), rom_id.to_string())];
973        if let Some(em) = emulator {
974            if !em.is_empty() {
975                query.push(("emulator".into(), em.to_string()));
976            }
977        }
978        let query_refs: Vec<(&str, &str)> = query
979            .iter()
980            .map(|(k, v)| (k.as_str(), v.as_str()))
981            .collect();
982        let headers = self.build_headers()?;
983        let resp = self
984            .http
985            .post(&url)
986            .headers(headers)
987            .query(&query_refs)
988            .multipart(form)
989            .send()
990            .await
991            .map_err(|e| anyhow!("state upload request: {e}"))?;
992        let status = resp.status();
993        if !status.is_success() {
994            let body = resp.text().await.unwrap_or_default();
995            return Err(anyhow!(
996                "ROMM API error: {} {} - {}",
997                status.as_u16(),
998                status.canonical_reason().unwrap_or(""),
999                body
1000            ));
1001        }
1002        let bytes = resp
1003            .bytes()
1004            .await
1005            .map_err(|e| anyhow!("read state upload body: {e}"))?;
1006        Ok(decode_json_response_body(&bytes))
1007    }
1008
1009    /// `POST /api/screenshots` with multipart field `screenshotFile`.
1010    pub async fn upload_screenshot_file(&self, rom_id: u64, file_path: &Path) -> Result<Value> {
1011        let url = format!("{}/api/screenshots", self.base_url.trim_end_matches('/'));
1012        let bytes = tokio::fs::read(file_path)
1013            .await
1014            .map_err(|e| anyhow!("read {}: {e}", file_path.display()))?;
1015        let fname = file_path
1016            .file_name()
1017            .and_then(|n| n.to_str())
1018            .ok_or_else(|| anyhow!("upload path must have a unicode filename"))?;
1019        let part = multipart::Part::bytes(bytes).file_name(fname.to_string());
1020        let form = multipart::Form::new().part("screenshotFile", part);
1021        let headers = self.build_headers()?;
1022        let resp = self
1023            .http
1024            .post(&url)
1025            .headers(headers)
1026            .query(&[("rom_id", rom_id.to_string().as_str())])
1027            .multipart(form)
1028            .send()
1029            .await
1030            .map_err(|e| anyhow!("screenshot upload: {e}"))?;
1031        let status = resp.status();
1032        if !status.is_success() {
1033            let body = resp.text().await.unwrap_or_default();
1034            return Err(anyhow!(
1035                "ROMM API error: {} {} - {}",
1036                status.as_u16(),
1037                status.canonical_reason().unwrap_or(""),
1038                body
1039            ));
1040        }
1041        let bytes = resp
1042            .bytes()
1043            .await
1044            .map_err(|e| anyhow!("read screenshot body: {e}"))?;
1045        Ok(decode_json_response_body(&bytes))
1046    }
1047
1048    /// `POST /api/firmware?platform_id=` with multipart `files` (single file supported).
1049    pub async fn upload_firmware_file(&self, platform_id: u64, file_path: &Path) -> Result<Value> {
1050        let url = format!("{}/api/firmware", self.base_url.trim_end_matches('/'));
1051        let bytes = tokio::fs::read(file_path)
1052            .await
1053            .map_err(|e| anyhow!("read {}: {e}", file_path.display()))?;
1054        let fname = file_path
1055            .file_name()
1056            .and_then(|n| n.to_str())
1057            .ok_or_else(|| anyhow!("upload path must have a unicode filename"))?;
1058        let part = multipart::Part::bytes(bytes).file_name(fname.to_string());
1059        let form = multipart::Form::new().part("files", part);
1060        let headers = self.build_headers()?;
1061        let resp = self
1062            .http
1063            .post(&url)
1064            .headers(headers)
1065            .query(&[("platform_id", platform_id.to_string())])
1066            .multipart(form)
1067            .send()
1068            .await
1069            .map_err(|e| anyhow!("firmware upload: {e}"))?;
1070        let status = resp.status();
1071        if !status.is_success() {
1072            let body = resp.text().await.unwrap_or_default();
1073            return Err(anyhow!(
1074                "ROMM API error: {} {} - {}",
1075                status.as_u16(),
1076                status.canonical_reason().unwrap_or(""),
1077                body
1078            ));
1079        }
1080        let bytes = resp
1081            .bytes()
1082            .await
1083            .map_err(|e| anyhow!("read firmware body: {e}"))?;
1084        Ok(decode_json_response_body(&bytes))
1085    }
1086
1087    /// Authenticated GET returning raw bytes (e.g. save/state/firmware file or gamelist export).
1088    pub async fn get_bytes(&self, path: &str, query: &[(String, String)]) -> Result<Vec<u8>> {
1089        let url = format!(
1090            "{}/{}",
1091            self.base_url.trim_end_matches('/'),
1092            path.trim_start_matches('/')
1093        );
1094        let headers = self.build_headers()?;
1095        let query_refs: Vec<(&str, &str)> = query
1096            .iter()
1097            .map(|(k, v)| (k.as_str(), v.as_str()))
1098            .collect();
1099        let resp = self
1100            .http
1101            .get(&url)
1102            .headers(headers)
1103            .query(&query_refs)
1104            .send()
1105            .await
1106            .map_err(|e| anyhow!("GET {path}: {e}"))?;
1107        let status = resp.status();
1108        if !status.is_success() {
1109            let body = resp.text().await.unwrap_or_default();
1110            return Err(anyhow!(
1111                "ROMM API error: {} {} - {}",
1112                status.as_u16(),
1113                status.canonical_reason().unwrap_or(""),
1114                body
1115            ));
1116        }
1117        Ok(resp.bytes().await?.to_vec())
1118    }
1119
1120    /// POST returning raw bytes (e.g. gamelist XML).
1121    pub async fn post_bytes(
1122        &self,
1123        path: &str,
1124        query: &[(String, String)],
1125        json_body: Option<Value>,
1126    ) -> Result<Vec<u8>> {
1127        let url = format!(
1128            "{}/{}",
1129            self.base_url.trim_end_matches('/'),
1130            path.trim_start_matches('/')
1131        );
1132        let headers = self.build_headers()?;
1133        let query_refs: Vec<(&str, &str)> = query
1134            .iter()
1135            .map(|(k, v)| (k.as_str(), v.as_str()))
1136            .collect();
1137        let mut req = self.http.post(&url).headers(headers).query(&query_refs);
1138        if let Some(b) = json_body {
1139            req = req.json(&b);
1140        }
1141        let resp = req.send().await.map_err(|e| anyhow!("POST {path}: {e}"))?;
1142        let status = resp.status();
1143        if !status.is_success() {
1144            let body = resp.text().await.unwrap_or_default();
1145            return Err(anyhow!(
1146                "ROMM API error: {} {} - {}",
1147                status.as_u16(),
1148                status.canonical_reason().unwrap_or(""),
1149                body
1150            ));
1151        }
1152        Ok(resp.bytes().await?.to_vec())
1153    }
1154
1155    /// `POST /api/roms/{id}/manuals` — raw file body with `x-upload-filename` header.
1156    pub async fn upload_rom_manual(&self, rom_id: u64, file_path: &Path) -> Result<Value> {
1157        let fname = file_path
1158            .file_name()
1159            .and_then(|n| n.to_str())
1160            .ok_or_else(|| anyhow!("manual path must have a unicode filename"))?
1161            .to_string();
1162        let url = format!(
1163            "{}/api/roms/{}/manuals",
1164            self.base_url.trim_end_matches('/'),
1165            rom_id
1166        );
1167        let bytes = tokio::fs::read(file_path)
1168            .await
1169            .map_err(|e| anyhow!("read {}: {e}", file_path.display()))?;
1170        let mut headers = self.build_headers()?;
1171        headers.insert(
1172            reqwest::header::HeaderName::from_static("x-upload-filename"),
1173            HeaderValue::from_str(&fname).map_err(|_| anyhow!("invalid x-upload-filename"))?,
1174        );
1175        let resp = self
1176            .http
1177            .post(&url)
1178            .headers(headers)
1179            .body(bytes)
1180            .send()
1181            .await
1182            .map_err(|e| anyhow!("manual upload: {e}"))?;
1183        let status = resp.status();
1184        if !status.is_success() {
1185            let body = resp.text().await.unwrap_or_default();
1186            return Err(anyhow!(
1187                "ROMM API error: {} {} - {}",
1188                status.as_u16(),
1189                status.canonical_reason().unwrap_or(""),
1190                body
1191            ));
1192        }
1193        let out = resp.bytes().await?;
1194        Ok(decode_json_response_body(&out))
1195    }
1196}
1197
1198fn filename_hint(save_path: &Path) -> String {
1199    save_path
1200        .file_name()
1201        .and_then(|n| n.to_str())
1202        .unwrap_or("download.bin")
1203        .to_string()
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208    use super::*;
1209
1210    #[test]
1211    fn decode_json_empty_and_whitespace_to_null() {
1212        assert_eq!(decode_json_response_body(b""), Value::Null);
1213        assert_eq!(decode_json_response_body(b"  \n\t "), Value::Null);
1214    }
1215
1216    #[test]
1217    fn decode_json_object_roundtrip() {
1218        let v = decode_json_response_body(br#"{"a":1}"#);
1219        assert_eq!(v["a"], 1);
1220    }
1221
1222    #[test]
1223    fn decode_non_json_wrapped() {
1224        let v = decode_json_response_body(b"plain text");
1225        assert_eq!(v["_non_json_body"], "plain text");
1226    }
1227
1228    #[test]
1229    fn api_root_url_strips_trailing_api() {
1230        assert_eq!(
1231            super::api_root_url("http://localhost:8080/api"),
1232            "http://localhost:8080"
1233        );
1234        assert_eq!(
1235            super::api_root_url("http://localhost:8080/api/"),
1236            "http://localhost:8080"
1237        );
1238        assert_eq!(
1239            super::api_root_url("http://localhost:8080"),
1240            "http://localhost:8080"
1241        );
1242    }
1243
1244    #[test]
1245    fn openapi_spec_urls_try_primary_scheme_then_alt() {
1246        let urls = super::openapi_spec_urls("http://example.test");
1247        assert_eq!(urls[0], "http://example.test/openapi.json");
1248        assert_eq!(urls[1], "http://example.test/api/openapi.json");
1249        assert!(
1250            urls.iter()
1251                .any(|u| u == "https://example.test/openapi.json"),
1252            "{urls:?}"
1253        );
1254    }
1255}