Skip to main content

romm_cli/client/
download.rs

1use anyhow::{anyhow, Result};
2use reqwest::Url;
3use std::path::Path;
4use std::time::Instant;
5use tokio::io::AsyncWriteExt as _;
6
7use crate::config::normalize_romm_origin;
8use crate::core::interrupt::cancelled_error;
9
10use super::response::{read_error_response_text, romm_api_error};
11use super::RommClient;
12
13impl RommClient {
14    /// Downloads a ROM (or multiple ROMs as a zip) to the specified path.
15    pub async fn download_rom<F>(
16        &self,
17        rom_id: u64,
18        save_path: &Path,
19        mut on_progress: F,
20    ) -> Result<()>
21    where
22        F: FnMut(u64, u64) + Send,
23    {
24        self.download_rom_with_cancel(rom_id, save_path, |_, _| false, &mut on_progress)
25            .await
26    }
27
28    pub async fn download_rom_with_cancel<F, C>(
29        &self,
30        rom_id: u64,
31        save_path: &Path,
32        is_cancelled: C,
33        on_progress: &mut F,
34    ) -> Result<()>
35    where
36        F: FnMut(u64, u64) + Send,
37        C: FnMut(u64, u64) -> bool + Send,
38    {
39        let filename = filename_hint(save_path);
40        let query = vec![
41            ("rom_ids".to_string(), rom_id.to_string()),
42            ("filename".to_string(), filename),
43        ];
44        self.download_url_with_query_with_cancel(
45            "/api/roms/download",
46            &query,
47            save_path,
48            is_cancelled,
49            on_progress,
50        )
51        .await
52    }
53
54    /// Downloads an arbitrary URL to `save_path`, supporting auth headers and resume.
55    pub async fn download_url_with_cancel<F, C>(
56        &self,
57        url: &str,
58        save_path: &Path,
59        is_cancelled: C,
60        on_progress: &mut F,
61    ) -> Result<()>
62    where
63        F: FnMut(u64, u64) + Send,
64        C: FnMut(u64, u64) -> bool + Send,
65    {
66        self.download_url_with_query_with_cancel(url, &[], save_path, is_cancelled, on_progress)
67            .await
68    }
69
70    /// Downloads an arbitrary URL and query to `save_path`, supporting auth headers and resume.
71    pub async fn download_url_with_query_with_cancel<F, C>(
72        &self,
73        url: &str,
74        query: &[(String, String)],
75        save_path: &Path,
76        mut is_cancelled: C,
77        on_progress: &mut F,
78    ) -> Result<()>
79    where
80        F: FnMut(u64, u64) + Send,
81        C: FnMut(u64, u64) -> bool + Send,
82    {
83        let url = self.resolve_download_url(url)?;
84        let filename = filename_hint(save_path);
85        let mut headers = self.build_headers()?;
86
87        let existing_len = tokio::fs::metadata(save_path)
88            .await
89            .map(|m| m.len())
90            .unwrap_or(0);
91
92        if existing_len > 0 {
93            let range = format!("bytes={existing_len}-");
94            if let Ok(v) = reqwest::header::HeaderValue::from_str(&range) {
95                headers.insert(reqwest::header::RANGE, v);
96            }
97        }
98
99        if let Some(parent) = save_path.parent() {
100            tokio::fs::create_dir_all(parent)
101                .await
102                .map_err(|e| anyhow!("create download parent dir {:?}: {e}", parent))?;
103        }
104
105        let t0 = Instant::now();
106        let mut resp = self
107            .http
108            .get(&url)
109            .headers(headers)
110            .query(query)
111            .send()
112            .await
113            .map_err(|e| anyhow!("download request error: {e}"))?;
114
115        let status = resp.status();
116        if self.verbose {
117            tracing::info!(
118                "[romm-cli] GET {} filename={:?} -> {} ({}ms)",
119                url,
120                filename,
121                status.as_u16(),
122                t0.elapsed().as_millis()
123            );
124        }
125        if !status.is_success() {
126            let body = read_error_response_text(resp).await;
127            return Err(romm_api_error(status, &body));
128        }
129
130        let (mut received, total, mut file) = if status == reqwest::StatusCode::PARTIAL_CONTENT {
131            let remaining = resp.content_length().unwrap_or(0);
132            let total = existing_len + remaining;
133            let file = tokio::fs::OpenOptions::new()
134                .append(true)
135                .open(save_path)
136                .await
137                .map_err(|e| anyhow!("open file for append {:?}: {e}", save_path))?;
138            (existing_len, total, file)
139        } else {
140            let total = resp.content_length().unwrap_or(0);
141            let file = tokio::fs::File::create(save_path)
142                .await
143                .map_err(|e| anyhow!("create file {:?}: {e}", save_path))?;
144            (0u64, total, file)
145        };
146
147        if is_cancelled(received, total) {
148            return Err(cancelled_error());
149        }
150
151        while let Some(chunk) = resp.chunk().await.map_err(|e| anyhow!("read chunk: {e}"))? {
152            if is_cancelled(received, total) {
153                return Err(cancelled_error());
154            }
155            file.write_all(&chunk)
156                .await
157                .map_err(|e| anyhow!("write chunk {:?}: {e}", save_path))?;
158            received += chunk.len() as u64;
159            on_progress(received, total);
160        }
161
162        Ok(())
163    }
164
165    fn resolve_download_url(&self, url: &str) -> Result<String> {
166        let trimmed = url.trim();
167        if trimmed.is_empty() {
168            return Err(anyhow!("download URL cannot be empty"));
169        }
170        if let Ok(parsed) = Url::parse(trimmed) {
171            return Ok(parsed.to_string());
172        }
173
174        let base = Url::parse(&normalize_romm_origin(&self.base_url))
175            .map_err(|e| anyhow!("invalid RomM base URL: {e}"))?;
176        let joined = base
177            .join(trimmed)
178            .map_err(|e| anyhow!("could not resolve download URL {trimmed:?}: {e}"))?;
179        Ok(joined.to_string())
180    }
181}
182
183fn filename_hint(save_path: &Path) -> String {
184    save_path
185        .file_name()
186        .and_then(|n| n.to_str())
187        .unwrap_or("download.bin")
188        .to_string()
189}