Skip to main content

romm_cli/core/
download.rs

1//! Download state and management.
2//!
3//! `DownloadJob` holds per-download progress/status.
4//! `DownloadManager` owns the shared job list and spawns background tasks.
5
6use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicUsize, Ordering};
8use std::sync::{Arc, Mutex};
9
10use crate::client::RommClient;
11use crate::core::utils;
12use crate::types::Rom;
13
14/// Directory for ROM zip downloads (`ROMM_DOWNLOAD_DIR` or `./downloads`).
15pub fn download_directory() -> PathBuf {
16    std::env::var("ROMM_DOWNLOAD_DIR")
17        .map(PathBuf::from)
18        .unwrap_or_else(|_| PathBuf::from("./downloads"))
19}
20
21/// Pick `stem.zip`, then `stem__2.zip`, `stem__3.zip`, … until the path does not exist.
22pub fn unique_zip_path(dir: &Path, stem: &str) -> PathBuf {
23    let mut n = 1u32;
24    loop {
25        let name = if n == 1 {
26            format!("{}.zip", stem)
27        } else {
28            format!("{}__{}.zip", stem, n)
29        };
30        let p = dir.join(name);
31        if !p.exists() {
32            return p;
33        }
34        n = n.saturating_add(1);
35    }
36}
37
38// ---------------------------------------------------------------------------
39// Job status / data
40// ---------------------------------------------------------------------------
41
42/// High-level status of a single download.
43#[derive(Debug, Clone)]
44pub enum DownloadStatus {
45    Downloading,
46    Done,
47    Error(String),
48}
49
50/// A single background download job (for one ROM).
51#[derive(Debug, Clone)]
52pub struct DownloadJob {
53    pub id: usize,
54    pub rom_id: u64,
55    pub name: String,
56    pub platform: String,
57    /// 0.0 ..= 1.0
58    pub progress: f64,
59    pub status: DownloadStatus,
60}
61
62static NEXT_JOB_ID: AtomicUsize = AtomicUsize::new(0);
63
64impl DownloadJob {
65    /// Construct a new job in the `Downloading` state.
66    pub fn new(rom_id: u64, name: String, platform: String) -> Self {
67        Self {
68            id: NEXT_JOB_ID.fetch_add(1, Ordering::Relaxed),
69            rom_id,
70            name,
71            platform,
72            progress: 0.0,
73            status: DownloadStatus::Downloading,
74        }
75    }
76
77    /// Progress as percentage 0..=100.
78    pub fn percent(&self) -> u16 {
79        (self.progress * 100.0).round().min(100.0) as u16
80    }
81}
82
83// ---------------------------------------------------------------------------
84// Manager
85// ---------------------------------------------------------------------------
86
87/// Owns the shared download list and spawns background download tasks.
88///
89/// Frontends only need an `Arc<Mutex<Vec<DownloadJob>>>` to inspect jobs.
90#[derive(Clone)]
91pub struct DownloadManager {
92    jobs: Arc<Mutex<Vec<DownloadJob>>>,
93}
94
95impl Default for DownloadManager {
96    fn default() -> Self {
97        Self::new()
98    }
99}
100
101impl DownloadManager {
102    pub fn new() -> Self {
103        Self {
104            jobs: Arc::new(Mutex::new(Vec::new())),
105        }
106    }
107
108    /// Shared handle for observers (TUI, GUI, tests) to inspect jobs.
109    pub fn shared(&self) -> Arc<Mutex<Vec<DownloadJob>>> {
110        self.jobs.clone()
111    }
112
113    /// Start downloading `rom` in the background; returns immediately.
114    ///
115    /// Progress updates are pushed into the shared `jobs` list so that
116    /// any frontend can render them.
117    pub fn start_download(&self, rom: &Rom, client: RommClient) {
118        let platform = rom
119            .platform_display_name
120            .as_deref()
121            .or(rom.platform_custom_name.as_deref())
122            .unwrap_or("—")
123            .to_string();
124
125        let job = DownloadJob::new(rom.id, rom.name.clone(), platform);
126        let job_id = job.id;
127        let rom_id = rom.id;
128        let fs_name = rom.fs_name.clone();
129        match self.jobs.lock() {
130            Ok(mut jobs) => jobs.push(job),
131            Err(err) => {
132                eprintln!("warning: download job list lock poisoned: {}", err);
133                return;
134            }
135        }
136
137        let jobs = self.jobs.clone();
138        tokio::spawn(async move {
139            let save_dir = download_directory();
140            if let Err(err) = tokio::fs::create_dir_all(&save_dir).await {
141                eprintln!(
142                    "warning: failed to create download directory {:?}: {}",
143                    save_dir, err
144                );
145            }
146            let base = utils::sanitize_filename(&fs_name);
147            let stem = base.rsplit_once('.').map(|(s, _)| s).unwrap_or(&base);
148            let save_path = unique_zip_path(&save_dir, stem);
149
150            let on_progress = |received: u64, total: u64| {
151                let p = if total > 0 {
152                    received as f64 / total as f64
153                } else {
154                    0.0
155                };
156
157                if let Ok(mut list) = jobs.lock() {
158                    if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
159                        j.progress = p;
160                    }
161                }
162            };
163
164            let download_result = client.download_rom(rom_id, &save_path, on_progress).await;
165            if download_result.is_err() {
166                let _ = tokio::fs::remove_file(&save_path).await;
167            }
168            match download_result {
169                Ok(()) => {
170                    if let Ok(mut list) = jobs.lock() {
171                        if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
172                            j.status = DownloadStatus::Done;
173                            j.progress = 1.0;
174                        }
175                    }
176                }
177                Err(e) => {
178                    if let Ok(mut list) = jobs.lock() {
179                        if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
180                            j.status = DownloadStatus::Error(e.to_string());
181                        }
182                    }
183                }
184            }
185        });
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use std::io::Write;
193    use std::time::{SystemTime, UNIX_EPOCH};
194
195    #[test]
196    fn unique_zip_path_skips_existing_files() {
197        let ts = SystemTime::now()
198            .duration_since(UNIX_EPOCH)
199            .unwrap()
200            .as_nanos();
201        let dir = std::env::temp_dir().join(format!("romm-dl-test-{ts}"));
202        std::fs::create_dir_all(&dir).unwrap();
203        let p1 = dir.join("game.zip");
204        std::fs::File::create(&p1).unwrap().write_all(b"x").unwrap();
205        let p2 = unique_zip_path(&dir, "game");
206        assert_eq!(p2.file_name().unwrap(), "game__2.zip");
207        let _ = std::fs::remove_file(&p1);
208        let _ = std::fs::remove_dir(&dir);
209    }
210}