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